Private/Write-AACTerraformPlanHtml.ps1

function Write-AACTerraformPlanHtml {
    <#
    .SYNOPSIS
        Writes Get-AACTerraformPlan's result as an interactive HTML report:
        tiles and charts that filter a table of the resource changes, one of
        every attribute change, the outputs and what changed outside
        Terraform.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Plan,

        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $Title,

        [System.Collections.IDictionary] $Detail
    )

    $stats = $Plan.Stats
    $actionTones = @{ Create = 'good'; Update = 'warn'; Replace = 'violet'; Delete = 'bad'; Read = 'info'; Import = 'info'; Move = 'info'; Forget = 'neutral'; NoOp = 'neutral' }
    $changeTones = @{ Added = 'good'; Removed = 'bad'; Modified = 'warn'; 'Known after apply' = 'neutral'; Reordered = 'info'; Reformatted = 'info' }
    $resources = @($Plan.Selected | Where-Object { $_.Source -eq 'Plan' -and $_.Mode -ne 'output' })
    $outputs = @($Plan.Selected | Where-Object { $_.Mode -eq 'output' })
    $drift = @($Plan.Selected | Where-Object { $_.Source -eq 'Drift' })
    $attributes = [System.Collections.Generic.List[object]]::new()
    foreach ($change in $resources) { $attributes.AddRange([object[]]$change.Attributes) }

    $tiles = @(
        @{ Value = '{0:N0}' -f $stats.Create; Label = 'to create'; Tone = 'good'; Table = 'resources'; Filters = @{ Action = 'Create' } }
        @{ Value = '{0:N0}' -f $stats.Update; Label = 'to update in place'; Tone = 'warn'; Table = 'resources'; Filters = @{ Action = 'Update' } }
        @{ Value = '{0:N0}' -f $stats.Replace; Label = 'to replace'; Tone = 'violet'; Table = 'resources'; Filters = @{ Action = 'Replace' } }
        @{ Value = '{0:N0}' -f $stats.Delete; Label = 'to delete'; Tone = $(if ($stats.Delete) { 'bad' } else { 'good' }); Table = 'resources'; Filters = @{ Action = 'Delete' } }
        @{ Value = '{0:N0}' -f ($stats.Import + $stats.Move); Label = 'to import or move'; Tone = 'info'; Table = 'resources' }
        @{ Value = '{0:N0}' -f $stats.Forced; Label = 'attributes forcing replacement'; Tone = $(if ($stats.Forced) { 'bad' } else { 'neutral' }); Table = 'attributes'; Filters = @{ ForcesReplacement = 'true' } }
        @{ Value = '{0:N0}' -f $stats.Drift; Label = 'changed outside Terraform'; Tone = $(if ($stats.Drift) { 'warn' } else { 'neutral' }); Table = 'drift' }
    )
    $charts = @(
        @{ Title = 'Resources by action'; Kind = 'donut'; CenterLabel = 'resources'; Table = 'resources'; Column = 'Action'; Items = @($resources | Group-Object Action | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $actionTones[$_.Name] } }) }
        @{ Title = 'Attribute changes'; Kind = 'donut'; CenterLabel = 'attributes'; Table = 'attributes'; Column = 'Change'; Items = @($attributes | Group-Object Change | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Tone = $changeTones[$_.Name] } }) }
        @{ Title = 'Resource types changing most'; Wide = $true; Tone = 'info'; Table = 'resources'; Column = 'Type'; Items = @($resources | Group-Object Type | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, Name | Select-AACFirst 15 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count } }) }
    )
    $resourceColumns = @(
        @{ Key = 'Action'; Label = 'Action'; Type = 'badge'; Tones = $actionTones; Facet = $true }
        @{ Key = 'Address'; Label = 'Address'; Type = 'mono' }
        @{ Key = 'Type'; Label = 'Type'; Facet = $true }
        @{ Key = 'ResourceName'; Label = 'Azure name' }
        @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
        @{ Key = 'Location'; Label = 'Location'; Facet = $true }
        @{ Key = 'Module'; Label = 'Module'; Facet = $true }
        @{ Key = 'Reason'; Label = 'Why'; Type = 'wide' }
        @{ Key = 'ReplacePaths'; Label = 'Forces replacement'; Type = 'wide' }
        @{ Key = 'AttributeCount'; Label = 'Attributes'; Type = 'number'; Format = 'N0'; Sum = $true }
        @{ Key = 'ChangedAttributes'; Label = 'Changed attributes'; Type = 'wide' }
        @{ Key = 'PreviousAddress'; Label = 'Moved from'; Type = 'mono'; Hidden = $true }
        @{ Key = 'Deposed'; Label = 'Deposed object'; Type = 'mono'; Hidden = $true }
        @{ Key = 'ReplaceOrder'; Label = 'Replace order'; Hidden = $true }
        @{ Key = 'Provider'; Label = 'Provider'; Facet = $true; Hidden = $true }
        @{ Key = 'ResourceId'; Label = 'Resource ID'; Type = 'mono'; Hidden = $true }
    )
    $tables = @(
        @{
            Id = 'resources'; Title = 'Resource changes'; Note = 'Deletes and replacements first. A replacement destroys the resource and creates it again.'; Noun = 'resources'; File = 'terraform-plan-resources'
            Rows = $resources; GroupBy = @('Action', 'Type', 'Module', 'ResourceGroup'); Columns = $resourceColumns
        }
        @{
            Id = 'attributes'; Title = 'Attribute changes'; Note = "Each changed attribute, flattened to a path: tags[`"key`"], a block's [0] or [name=...], {json} inside a JSON string. Sensitive values read '(sensitive)'."; Noun = 'attribute changes'; File = 'terraform-plan-attributes'
            Rows = $attributes.ToArray(); GroupBy = @('Address', 'Action', 'Change', 'Type')
            Columns = @(
                @{ Key = 'Action'; Label = 'Action'; Type = 'badge'; Tones = $actionTones; Facet = $true }
                @{ Key = 'Address'; Label = 'Address'; Type = 'mono'; Facet = $true }
                @{ Key = 'Type'; Label = 'Type'; Facet = $true; Hidden = $true }
                @{ Key = 'ResourceName'; Label = 'Azure name'; Hidden = $true }
                @{ Key = 'Attribute'; Label = 'Attribute'; Type = 'mono' }
                @{ Key = 'Change'; Label = 'Change'; Type = 'badge'; Tones = $changeTones; Facet = $true }
                @{ Key = 'Before'; Label = 'Before'; Type = 'wide' }
                @{ Key = 'After'; Label = 'After'; Type = 'wide' }
                @{ Key = 'ForcesReplacement'; Label = 'Forces replacement'; Type = 'badge'; Tones = @{ 'true' = 'bad'; 'false' = 'neutral' }; Facet = $true }
                @{ Key = 'Sensitive'; Label = 'Sensitive'; Facet = $true; Hidden = $true }
            )
        }
    )
    if ($outputs.Count) {
        $tables += @{
            Id = 'outputs'; Title = 'Outputs'; Noun = 'outputs'; File = 'terraform-plan-outputs'
            Rows = @($outputs | ForEach-Object { $_.Attributes }); GroupBy = @('Action')
            Columns = @(
                @{ Key = 'Action'; Label = 'Action'; Type = 'badge'; Tones = $actionTones; Facet = $true }
                @{ Key = 'Address'; Label = 'Output'; Type = 'mono' }
                @{ Key = 'Attribute'; Label = 'Value path'; Type = 'mono'; Hidden = $true }
                @{ Key = 'Before'; Label = 'Before'; Type = 'wide' }
                @{ Key = 'After'; Label = 'After'; Type = 'wide' }
            )
        }
    }
    $tables += @{
        Id = 'drift'; Title = 'Changed outside Terraform'; Note = 'What changed since the last terraform apply (resource_drift). The plan above already accounts for it.'; Noun = 'resources'; File = 'terraform-plan-drift'
        Rows = $drift; GroupBy = @('Action', 'Type'); Columns = $resourceColumns
    }

    $notices = @(foreach ($text in @($Plan.Notice | Where-Object { $_ })) { @{ Tone = $(if ($text -match 'error|not applyable|incomplete|^Check .* fails') { 'bad' } else { 'info' }); Text = $text } })
    $summary = "Plan: $($stats.ToAdd) to add, $($stats.ToChange) to change, $($stats.ToDestroy) to destroy$(if ($stats.Import) { ", $($stats.Import) to import" })."
    Write-AACHtmlReport -Path $Path -Title $Title -Subtitle $summary -Fact $Detail -Tile $tiles -Chart $charts -Table $tables -Notice $notices
}