Public/Get-AACAdvisorRecommendation.ps1

function Get-AACAdvisorRecommendation {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Gets a consolidated, flattened view of Azure Advisor recommendations
        (Resource Graph's advisorresources table): a Spectre.Console summary
        at the prompt, PowerShell objects down a pipeline, and optional CSV
        PDF and interactive HTML exports.
    .DESCRIPTION
        Reads every Azure Advisor recommendation the signed-in account can
        see (or only those in -SubscriptionId) from the advisorresources table
        in Azure Resource Graph, over REST with the Connect-AAC sign-in - no
        Az modules needed. Every Advisor category is covered: Cost, Security,
        Reliability (HighAvailability in the API), Operational excellence and
        Performance.
 
        Each recommendation's nested JSON is flattened to one flat row:
        subscription name, resource group, impacted resource name and type,
        category, impact, problem and solution, estimated monthly and annual
        savings with currency, retirement date and feature (for service
        retirement recommendations), last updated time and links. Advisor's
        free-form extendedProperties bag, whose keys differ per recommendation
        type, is always included as one "key=value; key=value" column; with
        -ExpandExtendedProperty each key also gets its own Ext_<key> column.
 
        Every object carries exactly the same properties (including every
        Ext_ column found in the whole result), because Export-Csv takes its
        header row from the first object only - ragged objects would silently
        lose columns.
 
        Postponed and dismissed recommendations (Advisor suppressions) are
        left out, as in the Azure portal; -IncludeSuppressed brings them back
        with Status 'Postponed' or 'Dismissed'.
 
        What you get depends on where the command runs:
          at the prompt a Spectre.Console view: the account and scope,
                           tiles with the number of recommendations, high /
                           medium / low impact, resources affected and
                           estimated monthly savings, then one colour-coded
                           table per category listing every affected
                           resource, shown a screen at a time
          piped onward the AAC.AdvisorRecommendation objects, with no
                           summary (e.g. | Where-Object, | Export-Csv)
          -PassThru the summary and the objects, e.g. to keep them
                           in a variable
          -NoDisplay the objects only, never the summary (scripts,
                           scheduled tasks)
 
        PowerShell can't tell "$r = Get-AACAdvisorRecommendation" from a
        plain call, so to capture the objects in a variable add -PassThru
        or -NoDisplay.
 
        In the tables, rows are grouped by recommendation under an impact
        badge (HIGH red, MEDIUM orange, LOW grey), savings are green, and a
        retirement date is red within 90 days, orange within 180 and gold
        after that. When the view is longer than the terminal it is paged:
        press any key for the next page, or A to show the rest. -NoPaging
        turns that off; paging is also skipped automatically when output is
        redirected.
 
        Exports:
          -CsvPath a CSV file written with Export-Csv (UTF-8, one row per
                      recommendation per resource)
          -PdfPath a landscape A4 PDF: a summary (totals, category by
                      impact, subscriptions, estimated savings), every
                      recommendation type consolidated with its affected
                      resource count, then one section per category listing
                      the affected resources under each recommendation
          -HtmlPath a self-contained, interactive HTML report: clickable
                      tiles and charts (by category, impact, subscription,
                      recommendation) that filter a table of every
                      recommendation, grouped by recommendation, with
                      search, filters, sorting, subtotals of savings, Azure
                      portal links and a CSV download of what is shown
 
        When any of -CsvPath, -PdfPath or -HtmlPath is given, the console
        shows only the progress and the files written - the report is in
        the files. Add -PassThru to get the objects as well.
 
        Savings are Advisor's own estimates. Two recommendations can overlap
        (e.g. a reservation and a right-size for the same VM), so a total is
        an upper bound; totals are kept per currency, never converted.
 
        PDF export needs Windows and PowerShell 7.4 or later; objects and
        CSV work everywhere.
    .PARAMETER SubscriptionId
        Only get recommendations in these subscriptions. Defaults to every
        subscription the signed-in account can see.
    .PARAMETER Category
        Only get these categories: Cost, Security, Reliability,
        OperationalExcellence, Performance.
    .PARAMETER Impact
        Only get recommendations with these impacts: High, Medium, Low.
    .PARAMETER IncludeSuppressed
        Also get recommendations that were postponed or dismissed in
        Advisor, with Status set to 'Postponed' or 'Dismissed'.
    .PARAMETER ExpandExtendedProperty
        Add one Ext_<key> column per key of Advisor's extendedProperties bag
        (the union of keys over every recommendation returned), next to the
        combined ExtendedProperties column.
    .PARAMETER CsvPath
        Also write the recommendations to this CSV file. An existing file is
        overwritten; missing folders are created.
    .PARAMETER PdfPath
        Also write the report to this PDF file. An existing file is
        overwritten; missing folders are created.
    .PARAMETER HtmlPath
        Also write an interactive HTML report to this file. An existing file
        is overwritten; missing folders are created.
    .PARAMETER Title
        The PDF and HTML report's title. Defaults to 'Azure Advisor
        recommendations'.
    .PARAMETER PassThru
        Show the summary and also return the recommendation objects.
    .PARAMETER NoDisplay
        Return the recommendation objects without showing the summary.
    .PARAMETER NoPaging
        Show the whole view at once instead of a screen at a time.
    .EXAMPLE
        Connect-AAC
        Get-AACAdvisorRecommendation
        Shows the summary of every Advisor recommendation you can see.
    .EXAMPLE
        Get-AACAdvisorRecommendation -CsvPath .\out\Advisor.csv -PdfPath .\out\Advisor.pdf -HtmlPath .\out\Advisor.html
        Writes every recommendation to a CSV file, a PDF report and an interactive HTML report.
    .EXAMPLE
        Get-AACAdvisorRecommendation -Category Cost |
            Group-Object SavingsCurrency |
            ForEach-Object { '{0} {1:N2} per month' -f $_.Name, ($_.Group | Measure-Object MonthlySavings -Sum).Sum }
        Totals Advisor's estimated monthly savings, per currency.
    .EXAMPLE
        $high = Get-AACAdvisorRecommendation -Impact High -PassThru
        Shows the summary of high-impact recommendations and keeps the objects in $high.
    .EXAMPLE
        Get-AACAdvisorRecommendation -Category Reliability |
            Where-Object RetirementDate |
            Sort-Object RetirementDate |
            Format-Table RetirementDate, RetiringFeature, ResourceName, SubscriptionName
        Lists resources affected by upcoming Azure service retirements, soonest first.
    .EXAMPLE
        Get-AACAdvisorRecommendation -NoDisplay -ExpandExtendedProperty -CsvPath .\Advisor.csv
        In a scheduled script: writes the CSV, with every extendedProperties key as its own column, and shows nothing.
    .EXAMPLE
        Get-AACAdvisorRecommendation | Export-Csv -Path .\advisor.csv -NoTypeInformation -Delimiter ';'
        Uses Export-Csv directly, for control over its options.
    .OUTPUTS
        AAC.AdvisorRecommendation (piped onward, or with -PassThru or -NoDisplay)
    #>

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

        [ValidateSet('Cost', 'Security', 'Reliability', 'OperationalExcellence', 'Performance')]
        [string[]] $Category,

        [ValidateSet('High', 'Medium', 'Low')]
        [string[]] $Impact,

        [switch] $IncludeSuppressed,

        [switch] $ExpandExtendedProperty,

        [string] $CsvPath,

        [string] $PdfPath,

        [string] $HtmlPath,

        [string] $Title = 'Azure Advisor recommendations',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    # Piped onward (| Where-Object, | Export-Csv ...) the objects are the
    # point, so no summary is drawn over them.
    # An export means the report is in the files: the console shows only
    # the title, the progress and the files written.
    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $exporting = [bool]($CsvPath -or $PdfPath -or $HtmlPath)
    $showSummary = $interactive -and -not $exporting
    $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward

    # Resolve paths now, relative to the caller's location, so a bad path
    # fails before any Azure call.
    $csvFullPath = if ($CsvPath) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CsvPath) }
    $pdfFullPath = if ($PdfPath) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($PdfPath) }
    $htmlFullPath = if ($HtmlPath) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($HtmlPath) }

    $headers = @{ Authorization = "Bearer $(Get-AACAccessToken)" }

    # advisorresources also holds suppressions, configurations and scores,
    # so the type filter matters. Nested fields are flattened here; only the
    # extendedProperties bag (different keys per recommendation type) comes
    # back as an object.
    $recommendationQuery = @'
advisorresources
| where type =~ 'microsoft.advisor/recommendations'
| project id, name, subscriptionId, resourceGroup,
    category = tostring(properties.category),
    impact = tostring(properties.impact),
    impactedField = tostring(properties.impactedField),
    impactedValue = tostring(properties.impactedValue),
    resourceId = tostring(properties.resourceMetadata.resourceId),
    resourceType = tostring(properties.resourceMetadata.resourceType),
    problem = tostring(properties.shortDescription.problem),
    solution = tostring(properties.shortDescription.solution),
    potentialBenefits = tostring(properties.potentialBenefits),
    recommendationTypeId = tostring(properties.recommendationTypeId),
    learnMoreLink = tostring(properties.learnMoreLink),
    lastUpdated = tostring(properties.lastUpdated),
    extendedProperties = properties.extendedProperties
'@

    # A suppression's ID is its recommendation's ID + /suppressions/<name>.
    # ttl '-1' means dismissed; any other ttl is a postponement.
    $suppressionQuery = @'
advisorresources
| where type =~ 'microsoft.advisor/suppressions'
| project recommendationId = tolower(substring(id, 0, indexof(tolower(id), '/suppressions/'))),
    ttl = tostring(properties.ttl),
    expires = tostring(properties.expirationTimeStamp)
'@

    $subscriptionQuery = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name"

    # The title first, then a line per step - as every command shows them.
    if ($interactive) {
        Write-AACRule -Title 'Azure Admin Console :: Azure Advisor' -Color 'deepskyblue3_1'
    }
    $data = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'read' -Total 3 -Description 'Reading Advisor recommendations from Azure Resource Graph'
        $recommendationRows = @(Invoke-AACResourceGraphQuery -SubscriptionId $SubscriptionId -Headers $headers -Query $recommendationQuery)
        Update-AACProgress -Id 'read' -Increment 1 -Description 'Reading postponed and dismissed recommendations'
        $suppressionRows = @(Invoke-AACResourceGraphQuery -SubscriptionId $SubscriptionId -Headers $headers -Query $suppressionQuery)
        Update-AACProgress -Id 'read' -Increment 1 -Description 'Reading subscription names'
        $subscriptionRows = @(Invoke-AACResourceGraphQuery -Headers $headers -Query $subscriptionQuery)
        $subscriptionCount = @($recommendationRows | ForEach-Object { $_.subscriptionId } | Select-Object -Unique).Count
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} Advisor recommendation(s) in {1:N0} subscription(s)' -f $recommendationRows.Count, $subscriptionCount)
        @{ Recommendations = $recommendationRows; Suppressions = $suppressionRows; Subscriptions = $subscriptionRows }
    }

    $lastSegment = { param([string] $Id) if ($Id) { $Id.TrimEnd('/').Split('/')[-1] } }
    $toNumber = {
        param($Value)
        $number = 0.0
        if ($null -ne $Value -and [double]::TryParse([string]$Value, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$number)) { $number }
    }
    $toDate = {
        param($Value)
        $date = [datetimeoffset]::MinValue
        if ($Value -and [datetimeoffset]::TryParse([string]$Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$date)) { $date.UtcDateTime }
    }
    $toText = {
        param($Value)
        if ($null -eq $Value) { '' }
        elseif ($Value -is [string] -or $Value -is [ValueType]) { [string]$Value }
        else { $Value | ConvertTo-Json -Depth 10 -Compress }
    }

    $subscriptionNames = @{}
    foreach ($subscription in $data.Subscriptions) {
        $subscriptionNames[$subscription.subscriptionId] = $subscription.name
    }
    $suppressions = @{}
    foreach ($suppression in $data.Suppressions) {
        if ($suppression.recommendationId) {
            $suppressions[$suppression.recommendationId] = $suppression
        }
    }

    # The portal's names; the API still says HighAvailability for Reliability.
    $categoryNames = @{ HighAvailability = 'Reliability' }
    $categoryOrder = @{ Cost = 0; Security = 1; Reliability = 2; OperationalExcellence = 3; Performance = 4 }
    $impactOrder = @{ High = 0; Medium = 1; Low = 2 }

    $recommendations = foreach ($row in $data.Recommendations) {
        $categoryName = if ($categoryNames.ContainsKey([string]$row.category)) { $categoryNames[[string]$row.category] } else { [string]$row.category }
        if ($Category -and $categoryName -notin $Category) { continue }
        if ($Impact -and [string]$row.impact -notin $Impact) { continue }

        $suppression = $suppressions[([string]$row.id).ToLowerInvariant()]
        $status = if (-not $suppression) { 'Active' } elseif ($suppression.ttl -eq '-1') { 'Dismissed' } else { 'Postponed' }
        if ($status -ne 'Active' -and -not $IncludeSuppressed) { continue }

        $extended = [ordered]@{}
        if ($row.extendedProperties -is [System.Management.Automation.PSCustomObject]) {
            foreach ($property in @($row.extendedProperties.PSObject.Properties | Sort-Object -Property Name)) {
                $extended[$property.Name] = & $toText $property.Value
            }
        }
        $ext = { param([string] $Name) if ($extended.Contains($Name)) { $extended[$Name] } }

        # A subscription-level recommendation has no resource group and its
        # resource ID is the subscription itself.
        $resourceId = if ($row.resourceId) { [string]$row.resourceId } else { '' }
        $retiringFeature = & $ext 'retirementFeatureName'
        if (-not $retiringFeature) { $retiringFeature = & $ext 'retiringFeature' }

        [pscustomobject]@{
            PSTypeName           = 'AAC.AdvisorRecommendation'
            Category             = $categoryName
            Impact               = [string]$row.impact
            Status               = $status
            SubscriptionName     = $subscriptionNames[$row.subscriptionId]
            SubscriptionId       = $row.subscriptionId
            ResourceGroup        = [string]$row.resourceGroup
            ResourceName         = if ($row.impactedValue) { [string]$row.impactedValue } else { & $lastSegment $resourceId }
            ResourceType         = if ($row.resourceType) { [string]$row.resourceType } else { [string]$row.impactedField }
            Problem              = [string]$row.problem
            Solution             = [string]$row.solution
            PotentialBenefits    = [string]$row.potentialBenefits
            SubCategory          = [string](& $ext 'recommendationSubCategory')
            MonthlySavings       = & $toNumber (& $ext 'savingsAmount')
            AnnualSavings        = & $toNumber (& $ext 'annualSavingsAmount')
            SavingsCurrency      = [string](& $ext 'savingsCurrency')
            RetirementDate       = & $toDate (& $ext 'retirementDate')
            RetiringFeature      = [string]$retiringFeature
            LastUpdated          = & $toDate $row.lastUpdated
            SuppressionExpires   = if ($suppression) { & $toDate $suppression.expires } else { $null }
            RecommendationTypeId = [string]$row.recommendationTypeId
            LearnMoreLink        = [string]$row.learnMoreLink
            ResourceId           = $resourceId
            RecommendationId     = [string]$row.id
            ExtendedProperties   = ($extended.Keys | ForEach-Object { "$_=$($extended[$_])" }) -join '; '
            _Extended            = $extended
        }
    }

    $recommendations = @($recommendations | Sort-Object -Property @(
            @{ Expression = { $o = $categoryOrder[$_.Category]; if ($null -eq $o) { 9 } else { $o } } }
            @{ Expression = { $o = $impactOrder[$_.Impact]; if ($null -eq $o) { 9 } else { $o } } }
            'Problem', 'SubscriptionName', 'ResourceGroup', 'ResourceName'
        ))

    # Give every object the same Ext_ columns (the union over the whole
    # result, sorted), then drop the working copy of the bag.
    $extendedKeys = if ($ExpandExtendedProperty) {
        @($recommendations | ForEach-Object { $_._Extended.Keys } | Sort-Object -Unique)
    }
    foreach ($recommendation in $recommendations) {
        foreach ($key in $extendedKeys) {
            $value = if ($recommendation._Extended.Contains($key)) { $recommendation._Extended[$key] } else { '' }
            $recommendation.PSObject.Properties.Add([psnoteproperty]::new("Ext_$key", $value))
        }
        $recommendation.PSObject.Properties.Remove('_Extended')
    }

    # Where the recommendations came from, for the summary and the PDF.
    $scope = [ordered]@{
        Subscriptions = if ($SubscriptionId) { $SubscriptionId -join ', ' } else { 'every subscription the account can see' }
    }
    if ($Category) { $scope['Categories'] = $Category -join ', ' }
    if ($Impact) { $scope['Impact'] = $Impact -join ', ' }
    $scope['Postponed / dismissed'] = if ($IncludeSuppressed) { 'included' } else { 'not included' }

    if ($recommendations.Count -eq 0 -and -not $showSummary) {
        Write-Warning 'No Azure Advisor recommendations were found for the signed-in account and the given filters.'
    }

    $null = Invoke-AACExport -CsvPath $csvFullPath -CsvObject $recommendations -Noun 'recommendation' -PdfPath $pdfFullPath -WritePdf {
        Write-AACAdvisorRecommendationPdf -Recommendation $recommendations -Path $pdfFullPath -Title $Title -Detail $scope
    } -HtmlPath $htmlFullPath -WriteHtml {
        Write-AACAdvisorRecommendationHtml -Recommendation $recommendations -Path $htmlFullPath -Title $Title -Detail $scope
    }

    if ($showSummary) {
        Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock {
            Show-AACAdvisorSummary -Recommendation $recommendations -Scope $scope -NoTitle
            Show-AACAdvisorTable -Recommendation $recommendations
            [Spectre.Console.AnsiConsole]::WriteLine()
            if ($recommendations.Count -gt 0) {
                Write-AACMarkup '[grey42]Add -PassThru (or pipe the command) for the objects; -CsvPath, -PdfPath or -HtmlPath for a report.[/]'
            }
        }
    }

    if ($returnObjects) {
        $recommendations
    }
}