Public/Invoke-AACCosmosDBAssessment.ps1

function Invoke-AACCosmosDBAssessment {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Assesses Azure Cosmos DB accounts across subscriptions -
        availability, rate limiting (429), errors, throughput and its use,
        storage, every database and container, security, resilience and
        cost - as a heat map, findings, a Spectre.Console view, objects, and
        CSV, PDF and interactive HTML reports.
    .DESCRIPTION
        Read-only (Reader is enough), for every API - NoSQL, MongoDB,
        Cassandra, Gremlin and Table:
          Resource Graph the accounts: regions, failover, consistency,
                            backup, capabilities (API, serverless), network
                            access, local authentication, TLS, encryption -
                            and the Defender for Azure Cosmos DB plan and its
                            alerts
          Resource Manager each account's databases and containers, their
                            throughput (manual or autoscale, at the database
                            or the container), and where its logs go
          metrics over -TimeRange: TotalRequests by status code,
                            TotalRequestUnits, NormalizedRUConsumption (the
                            busiest partition's share of its throughput),
                            ServiceAvailability, ServerSideLatency; and the
                            latest DataUsage, IndexUsage and DocumentCount
 
        Findings (High, Medium, Low):
          availability service availability below 99.99%
          throttling requests rate limited (429), running close to the
                        throughput
          errors server errors (5xx), slow server-side latency
          security TLS below 1.2, open to every network, account keys
                        allowed, keys able to change databases and
                        containers, no logs to Log Analytics, Defender off
                        or alerting
          resilience a single region, no automatic failover, no
                        availability zones, periodic backup only (or stored
                        in one datacentre), Strong consistency across regions
          cost throughput higher than needed, no requests in the
                        period, many small containers with their own
                        throughput
 
        The heat map scores each account 0-100 for availability, throttling,
        errors, security, resilience and cost - the 40 worst at the prompt
        and in the reports; clicking a cell in the HTML lists its findings.
 
        -View sets what is returned (piped onward, -PassThru, -NoDisplay) and
        written to -CsvPath: Account (default), Container or Finding.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups.
    .PARAMETER ResourceGroupName
        Only these resource groups.
    .PARAMETER Name
        Only accounts with these names; wildcards work.
    .PARAMETER TimeRange
        How far back the metrics go: 1h, 4h, 12h, 1d (default), 3d, 7d, 14d
        or 30d. Storage is always the latest value.
    .PARAMETER View
        Account, Container or Finding: what is returned and written to -CsvPath.
    .PARAMETER ThrottleLimit
        How many requests run at once (1 to 32; 12 by default).
    .PARAMETER CsvPath
        Write the -View rows to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the rows.
    .PARAMETER NoDisplay
        Return the rows without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Connect-AAC
        Invoke-AACCosmosDBAssessment
        Every account's availability, throttling, throughput, posture and findings over the last day.
    .EXAMPLE
        Invoke-AACCosmosDBAssessment -NoDisplay | Where-Object Throttled | Select-Object -Property Account, Throttled, ThrottleRate, NormalizedRuMax, ProvisionedRU, AutoscaleMaxRU
        The accounts rate limiting requests, and the throughput they have.
    .EXAMPLE
        Invoke-AACCosmosDBAssessment -View Container -NoDisplay | Where-Object ThroughputMode -EQ 'Manual' | Sort-Object -Property RU -Descending
        The containers with manual throughput, the most first - candidates for autoscale.
    .EXAMPLE
        Invoke-AACCosmosDBAssessment -ResourceGroupName 'rg-data' -TimeRange 7d -HtmlPath .\out\CosmosDB.html -PdfPath .\out\CosmosDB.pdf
        A week of a workload's accounts, as reports.
    .OUTPUTS
        AAC.CosmosDBAssessment, AAC.CosmosDBContainer or AAC.CosmosDBFinding, by -View (piped onward, or with -PassThru or -NoDisplay)
    #>

    [CmdletBinding()]
    [OutputType('AAC.CosmosDBAssessment', 'AAC.CosmosDBContainer', 'AAC.CosmosDBFinding')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [ValidateNotNullOrEmpty()]
        [string[]] $ManagementGroupId,

        [ValidateNotNullOrEmpty()]
        [string[]] $ResourceGroupName,

        [SupportsWildcards()]
        [string[]] $Name,

        [ValidateSet('1h', '4h', '12h', '1d', '3d', '7d', '14d', '30d')]
        [string] $TimeRange = '1d',

        [ValidateSet('Account', 'Container', 'Finding')]
        [string] $View = 'Account',

        [ValidateRange(1, 32)]
        [int] $ThrottleLimit = 12,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Cosmos DB assessment',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    # TimeRange -> hours and the metric grain (an interval Azure Monitor accepts).
    $range = @{ '1h' = @(1, 'PT5M'); '4h' = @(4, 'PT15M'); '12h' = @(12, 'PT30M'); '1d' = @(24, 'PT1H'); '3d' = @(72, 'PT6H'); '7d' = @(168, 'PT6H'); '14d' = @(336, 'PT12H'); '30d' = @(720, 'P1D') }[$TimeRange]
    $end = [datetime]::UtcNow
    $span = { param([int] $Hours) "$($end.AddHours(-$Hours).ToString('yyyy-MM-ddTHH:mm:ssZ'))/$($end.ToString('yyyy-MM-ddTHH:mm:ssZ'))" }
    $request = @{
        SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); Name = @($Name | Where-Object { $_ })
        Timespan = & $span $range[0]; Interval = $range[1]
        # ServiceAvailability is hourly at the finest.
        AvailabilityInterval = $(if ($range[1] -in 'PT5M', 'PT15M', 'PT30M') { 'PT1H' } else { $range[1] })
        # Storage is reported every few minutes to hourly: at least two days back, for the latest value.
        StorageTimespan = & $span ([Math]::Max(48, $range[0])); ThrottleLimit = $ThrottleLimit
    }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Cosmos DB assessment' -Color 'mediumorchid' }
    $state = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"

        Update-AACProgress -Id 'read' -Indeterminate -Description 'Reading the Cosmos DB accounts and Defender (Resource Graph)'
        $read = Invoke-AACGraphBatch -Query (Get-AACCosmosDBQuery -ResourceGroupName $request.ResourceGroupName) -SubscriptionId $scope.GraphScope -AllowFailure @('pricing', 'alerts', 'workspaces')
        $accounts = @($read.Rows['accounts'] | Where-Object { $n = [string]$_['name']; $null -ne $_ -and (-not $request.Name.Count -or @($request.Name | Where-Object { $n -like $_ }).Count) })
        Update-AACProgress -Id 'read' -Complete -Description ('Found {0:N0} account(s)' -f $accounts.Count)
        $notices = [System.Collections.Generic.List[string]]::new()
        foreach ($key in @($read.Errors.Keys | Sort-Object)) { $notices.Add("The $key couldn't be read: $($read.Errors[$key] -replace '\s+', ' ')") }

        # Each account's API and capacity mode decide where its databases are, and whether they have throughput.
        $described = foreach ($a in $accounts) {
            $props = $a['properties']
            $caps = @(@(if ($props -is [System.Collections.IDictionary]) { $props['capabilities'] }) | Where-Object { $_ } | ForEach-Object { [string]$_['name'] })
            $api = if ([string]$a['kind'] -eq 'MongoDB' -or $caps -contains 'EnableMongo') { 'MongoDB' } elseif ($caps -contains 'EnableCassandra') { 'Cassandra' } elseif ($caps -contains 'EnableGremlin') { 'Gremlin' } elseif ($caps -contains 'EnableTable') { 'Table' } else { 'NoSQL' }
            @{ Id = [string]$a['id']; Api = $api; Serverless = $caps -contains 'EnableServerless' }
        }
        $described = @($described)
        Update-AACProgress -Id 'resources' -Indeterminate -Description "Reading the databases, containers and throughput of $($described.Count) account(s)"
        $resources = Read-AACCosmosDBResource -Account $described -ThrottleLimit $request.ThrottleLimit
        Update-AACProgress -Id 'resources' -Complete -Description ('Read the databases and containers of {0:N0} account(s)' -f $described.Count)

        $main = foreach ($a in $described) {
            @{ Id = $a.Id; Key = 'Requests'; Metric = @('TotalRequests'); Aggregation = @('Count'); Filter = "StatusCode eq '*'" }
            @{ Id = $a.Id; Key = 'Usage'; Metric = @('TotalRequestUnits', 'NormalizedRUConsumption'); Aggregation = @('Total', 'Maximum'); Filter = '' }
            @{ Id = $a.Id; Key = 'Latency'; Metric = @('ServerSideLatency'); Aggregation = @('Average'); Filter = '' }
        }
        $availabilityCalls = @(foreach ($a in $described) { @{ Id = $a.Id; Key = 'Availability'; Metric = @('ServiceAvailability'); Aggregation = @('Average'); Filter = '' } })
        $storageCalls = @(foreach ($a in $described) { @{ Id = $a.Id; Key = 'Storage'; Metric = @('DataUsage', 'IndexUsage', 'DocumentCount'); Aggregation = @('Total'); Filter = '' } })
        $main = @($main)
        Update-AACProgress -Id 'metrics' -Total ([Math]::Max(1, $main.Count + $availabilityCalls.Count + $storageCalls.Count)) -Description 'Reading the metrics (Azure Monitor)'
        $tick = { param($Done, $Total) Update-AACProgress -Id 'metrics' -Increment 1 }
        $metrics = Read-AACNetworkHealthMetric -Call $main -Timespan $request.Timespan -Interval $request.Interval -ThrottleLimit $request.ThrottleLimit -OnProgress $tick
        $more = @(
            (Read-AACNetworkHealthMetric -Call $availabilityCalls -Timespan $request.Timespan -Interval $request.AvailabilityInterval -ThrottleLimit $request.ThrottleLimit -OnProgress $tick)
            (Read-AACNetworkHealthMetric -Call $storageCalls -Timespan $request.StorageTimespan -Interval 'PT1H' -ThrottleLimit $request.ThrottleLimit -OnProgress $tick)
        )
        foreach ($set in $more) { foreach ($id in @($set.Keys)) { if (-not $metrics.Contains($id)) { $metrics[$id] = @{} }; foreach ($k in @($set[$id].Keys)) { $metrics[$id][$k] = $set[$id][$k] } } }
        Update-AACProgress -Id 'metrics' -Complete -Description ('Read the metrics of {0:N0} account(s)' -f $described.Count)
        $failedMetrics = @($described | Where-Object { $m = $metrics[$_.Id]; $m -and $m['Requests'] -and $m['Requests'].Error }).Count
        if ($failedMetrics) { $notices.Add("Metrics couldn't be read for $failedMetrics account(s) - their health is left blank (see MetricsError).") }
        $unread = @($described | Where-Object { $resources[$_.Id].Error }).Count
        if ($unread) { $notices.Add("The databases of $unread account(s) couldn't be read - their containers and throughput are left out (see ResourcesError).") }

        $workspaceName = @{}
        foreach ($w in @($read.Rows['workspaces'])) { if ($w) { $workspaceName[[string]$w['id']] = [string]$w['name'] } }
        $result = ConvertTo-AACCosmosDBAssessment -Account $accounts -Resource $resources -Metric $metrics -Pricing @($read.Rows['pricing']) -Alert @($read.Rows['alerts']) -WorkspaceName $workspaceName -SubscriptionName $scope.Names
        @{ Result = $result; Scope = $scope; Notices = $notices.ToArray() }
    }

    # --- Report ------------------------------------------------------------------------------------------------
    $r = $state.Result
    $stats = $r.Stats
    $rank = Get-AACSeverityRank
    $accountRows = @($r.Accounts | Sort-Object -Property @{ Expression = { if ($null -eq $_.Score) { 101 } else { $_.Score } } }, Account)
    $containerRows = @($r.Containers | Sort-Object -Property @{ Expression = { if ($null -eq $_.RU) { -1 } else { $_.RU } }; Descending = $true }, Account, Database, Container)
    $availabilities = @($r.Accounts | Where-Object { $null -ne $_.Availability -and $_.Requests } | ForEach-Object { $_.Availability })
    $worstAvailability = if ($availabilities.Count) { ($availabilities | Measure-Object -Minimum).Minimum } else { $null }
    $yesNo = @{ True = 'good'; False = 'warn' }
    $report = @{
        Subtitle = "Cosmos DB - last $TimeRange"
        Facts    = [ordered]@{ Scope = $state.Scope.Label; 'Time range' = "$TimeRange (storage: the latest value)"; Generated = "$([datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture)) UTC" }
        Status   = $(if ($stats.High) { 'Failed' } elseif (@($r.Findings).Count) { 'Warning' } else { 'Success' })
        Headline = $(if (-not $stats.Accounts) { 'No Cosmos DB account in scope.' } else { "$($stats.Accounts) account(s), $($stats.Containers) container(s), $('{0:N0}' -f $stats.RU) RU/s provisioned, $('{0:N2}' -f $stats.DataGB) GB: $($stats.High) high finding(s); $('{0:N0}' -f $stats.Throttled) request(s) rate limited; $($stats.Public) open to every network, $($stats.SingleRegion) in a single region" })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $stats.Accounts; Label = 'accounts'; Tone = 'info'; Table = 'accounts' }
            @{ Value = '{0:N0}' -f $stats.Containers; Label = 'containers'; Tone = 'info'; Table = 'containers' }
            @{ Value = '{0:N0}' -f $stats.RU; Label = 'RU/s provisioned'; Tone = 'info'; Table = 'containers' }
            @{ Value = $(if ($null -eq $worstAvailability) { '-' } else { '{0:N2}' -f $worstAvailability }); Label = 'lowest availability %'; Tone = $(if ($null -eq $worstAvailability) { 'neutral' } elseif ($worstAvailability -ge 99.99) { 'good' } else { 'bad' }) }
            @{ Value = '{0:N0}' -f $stats.Throttled; Label = 'rate limited (429)'; Tone = $(if ($stats.Throttled) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Signal = 'Throttling' } }
            @{ Value = '{0:N0}' -f $stats.Public; Label = 'open to every network'; Tone = $(if ($stats.Public) { 'bad' } else { 'good' }); Table = 'posture'; Filters = @{ Network = 'Public' } }
            @{ Value = '{0:N0}' -f $stats.High; Label = 'high findings'; Tone = $(if ($stats.High) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Severity = 'High' } }
        )
        Notices  = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        HeatMaps = @($(if (@($r.HeatMap.Rows).Count) { $r.HeatMap }))
        Charts   = @(
            @{ Title = 'Requests by status'; Kind = 'donut'; CenterLabel = 'requests'; Items = @(foreach ($k in @($r.StatusCodes.Keys)) { if ($r.StatusCodes[$k]) { @{ Label = $k; Value = $r.StatusCodes[$k]; Tone = @{ Success = 'good'; 'Throttled (429)' = 'bad'; 'Server (5xx)' = 'bad'; 'Client (4xx)' = 'warn'; 'Not found, conflict (404, 409, 412)' = 'neutral' }[$k] } } }) }
            @{ Title = 'APIs'; Items = @($r.Accounts | Group-Object -Property Api | Sort-Object -Property Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'accounts'; Column = 'Api'; Tone = 'violet' }
            @{ Title = 'Findings by signal'; Items = @($r.Findings | Group-Object -Property Signal | Sort-Object -Property Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'findings'; Column = 'Signal'; Tone = 'warn'; Console = $true }
        )
        Tabs     = @(
            @{ Name = 'Overview'; Badge = $stats.Accounts; Tone = 'info'; Note = 'Each account: availability, requests by status, rate limiting, request units and the busiest partition, latency, throughput and storage.' }
            @{ Name = 'Containers'; Badge = $stats.Containers; Tone = 'violet'; Note = 'Every database and container: partition key, throughput mode and RU/s, TTL, analytical store and indexing.' }
            @{ Name = 'Security & resilience'; Badge = $stats.Public + $stats.SingleRegion; Tone = $(if ($stats.Public) { 'bad' } else { 'good' }); Note = 'Each account''s network access, authentication, TLS, encryption, logs and Defender - and its regions, failover, zones, consistency and backup.' }
            @{ Name = 'Findings'; Badge = @($r.Findings).Count; Tone = $(if ($stats.High) { 'bad' } else { 'warn' }); Note = 'What to fix, worst first.' }
        )
        Tables   = @(
            @{ Id = 'accounts'; Title = 'Overview'; Section = 'Overview'; Rows = $accountRows; Noun = 'accounts'; GroupBy = @('Subscription', 'ResourceGroup', 'Api', 'CapacityMode'); ConsoleLimit = 15
                Columns = @(
                    @{ Key = 'Account'; Label = 'Account'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'Score'; Label = 'Score'; Type = 'score'; Format = 'N0'; Console = $true; Pdf = $true }
                    @{ Key = 'Api'; Label = 'API'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Availability'; Label = 'Availability %'; Type = 'number'; Format = 'N3'; Console = $true; Pdf = $true }
                    @{ Key = 'Requests'; Label = 'Requests'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Throttled'; Label = '429s'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true }
                    @{ Key = 'NormalizedRuMax'; Label = 'Busiest partition %'; Type = 'number'; Format = 'N0'; Console = $true; Pdf = $true }
                    @{ Key = 'ProvisionedRU'; Label = 'Manual RU/s'; Type = 'number'; Sum = $true; Console = $true }
                    @{ Key = 'AutoscaleMaxRU'; Label = 'Autoscale max RU/s'; Type = 'number'; Sum = $true; Console = $true }
                    @{ Key = 'LatencyMs'; Label = 'Latency ms'; Type = 'number'; Format = 'N2' }
                    @{ Key = 'ServerErrors'; Label = 'Server errors'; Type = 'number'; Sum = $true }
                    @{ Key = 'ClientErrors'; Label = 'Client errors'; Type = 'number'; Sum = $true }
                    @{ Key = 'RequestUnits'; Label = 'Request units'; Type = 'number'; Sum = $true }
                    @{ Key = 'DataGB'; Label = 'Data GB'; Type = 'number'; Format = 'N2'; Sum = $true }
                    @{ Key = 'IndexGB'; Label = 'Index GB'; Type = 'number'; Format = 'N2'; Sum = $true }
                    @{ Key = 'Documents'; Label = 'Documents'; Type = 'number'; Sum = $true }
                    @{ Key = 'Databases'; Label = 'Databases'; Type = 'number'; Sum = $true }
                    @{ Key = 'Containers'; Label = 'Containers'; Type = 'number'; Sum = $true }
                    @{ Key = 'CapacityMode'; Label = 'Capacity'; Facet = $true }
                    @{ Key = 'Findings'; Label = 'Findings'; Type = 'number'; Sum = $true; Console = $true }
                    @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                ) }
            @{ Id = 'containers'; Title = 'Containers'; Section = 'Containers'; Rows = $containerRows; Noun = 'containers'; GroupBy = @('Account', 'Database', 'ThroughputMode'); ConsoleLimit = 20
                Empty = 'No database or container.'; EmptyStatus = 'Info'
                Columns = @(
                    @{ Key = 'Account'; Label = 'Account'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Database'; Label = 'Database'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Container'; Label = 'Container'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'PartitionKey'; Label = 'Partition key'; Console = $true; Pdf = $true }
                    @{ Key = 'ThroughputMode'; Label = 'Throughput'; Type = 'badge'; Tones = @{ Autoscale = 'good'; Manual = 'info'; 'Shared database' = 'violet'; Serverless = 'neutral' }; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'RU'; Label = 'RU/s'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'DefaultTtl'; Label = 'Default TTL'; Type = 'number' }
                    @{ Key = 'AnalyticalStore'; Label = 'Analytical store'; Facet = $true }
                    @{ Key = 'IndexingMode'; Label = 'Indexing'; Facet = $true }
                    @{ Key = 'Api'; Label = 'API'; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                ) }
            @{ Id = 'posture'; Title = 'Security & resilience'; Section = 'Security & resilience'; Rows = $accountRows; Noun = 'accounts'; GroupBy = @('Network', 'Consistency', 'Backup'); ConsoleLimit = 15
                Columns = @(
                    @{ Key = 'Account'; Label = 'Account'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'Network'; Label = 'Network'; Type = 'badge'; Tones = @{ Private = 'good'; Firewall = 'info'; Public = 'warn' }; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'LocalAuth'; Label = 'Keys allowed'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'MinTls'; Label = 'Min TLS'; Facet = $true; Console = $true }
                    @{ Key = 'RegionCount'; Label = 'Regions'; Type = 'number'; Console = $true; Pdf = $true }
                    @{ Key = 'AutomaticFailover'; Label = 'Auto failover'; Type = 'badge'; Tones = $yesNo; Facet = $true; Console = $true }
                    @{ Key = 'ZoneRedundant'; Label = 'Zones'; Type = 'badge'; Tones = $yesNo; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Consistency'; Label = 'Consistency'; Facet = $true; Console = $true }
                    @{ Key = 'Backup'; Label = 'Backup'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Regions'; Label = 'Region names' }
                    @{ Key = 'MultiRegionWrites'; Label = 'Multi-region writes'; Facet = $true }
                    @{ Key = 'BackupRedundancy'; Label = 'Backup storage'; Facet = $true }
                    @{ Key = 'PrivateEndpoints'; Label = 'Private endpoints'; Type = 'number' }
                    @{ Key = 'Encryption'; Label = 'Encryption'; Facet = $true }
                    @{ Key = 'LogsTo'; Label = 'Logs to'; Facet = $true }
                    @{ Key = 'Defender'; Label = 'Defender'; Type = 'badge'; Tones = @{ On = 'good'; Off = 'warn' }; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                ) }
            @{ Id = 'findings'; Title = 'Findings'; Section = 'Findings'; Rows = @($r.Findings); Noun = 'findings'; GroupBy = @('Signal', 'Finding', 'Severity', 'Account'); ConsoleLimit = 20
                Empty = 'Nothing to fix.'; EmptyStatus = 'Success'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Account'; Label = 'Account'; Type = 'resource'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Signal'; Label = 'Signal'; Facet = $true; Console = $true }
                    @{ Key = 'Finding'; Label = 'Finding'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Database'; Label = 'Database' }
                    @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Recommendation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                ) }
        )
        Hint     = '-View Container returns every container with its throughput; -TimeRange 7d looks further back; -Name or -ResourceGroupName narrows it; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    $rows = @(switch ($View) { 'Container' { $containerRows } 'Finding' { $r.Findings } default { $accountRows } })
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $rows -Noun $View.ToLowerInvariant() -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $rows -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}