Private/Write-AACHtmlReport.ps1

function Write-AACHtmlReport {
    <#
    .SYNOPSIS
        Writes a single, self-contained, interactive HTML report - the
        -HtmlPath export of Get-AACAdvisorRecommendation, Get-AACFirewallRule,
        Show-AACResource and Show-AACCost.
    .DESCRIPTION
        One file with no external scripts, styles or fonts (Private\
        DataReport.html with the data embedded as JSON), so it opens offline,
        from an e-mail or a pipeline artifact. Each command describes its
        report; the page makes it interactive:
 
          -Tile @{ Value; Label; Tone; Table; Filters = @{ Column = 'Value' } }
                    Tone is good, bad, warn, info, violet or neutral. With
                    Table (and Filters), clicking the tile filters that table.
          -Chart @{ Title; Kind ('bar', the default, or 'donut': each item's
                    share, the total in the middle - CenterLabel under it);
                    Items = @(@{ Label; Value; Display; Tone; Filter });
                       Format = 'N0'|'N2'; Suffix; Table; Column; BaseFilters;
                       Wide }
                    Horizontal bars; with Table and Column, clicking a bar
                    filters the table to Column = the bar's Filter (or Label),
                    plus BaseFilters (@{ Column = 'Value' }) if given.
          -Table @{ Id; Title; Note; Noun; File; Rows; Columns; Sort =
                       @{ Key; Desc }; GroupBy = @('Column', ...); Group;
                       Filters = @{ Column = 'Value' } (what it opens with);
                       PageSize; Section }
                    Section: tables with one are drawn under its heading
                    (in the order sections first appear) and listed in a
                    Contents block above the tables - for reports with many.
                    Columns: @{ Key; Label; Type; Facet; Hidden; Tones;
                    Format; Sum; CurrencyKey; IdKey; Href; Text; Soon }.
                    Types: text (default), wide (long text, clamped), mono,
                    number, money, score (0-100 as a percentage: 70+
                    green, 40-69 amber, under 40 red), path ('a → b → c': steps
                    as chips joined by arrows), date (Soon: red within 90 days, orange
                    within 180), datetime (date and time, local), badge (Tones = @{ Value = 'bad' }), link
                    (https only), resource (the name with Azure portal and
                    copy-ID buttons from the IdKey column, ResourceId by
                    default). Facet columns get a filter drop-down; Sum
                    columns are totalled for the rows shown and per group.
          -Notice @{ Tone; Text } lines under the tiles.
          -Tab @(@{ Name; Badge; Tone; Note }) turns the sections into
                    tabs: Overview (tiles, notices, charts, tree) first, then
                    one per section in this order (sections not listed after
                    them), each with its Badge (a count, coloured by Tone)
                    and Note above its tables. The tab shown is in the URL
                    (#tab=Name); a tile or chart that filters a table opens
                    its tab. Printed, every tab is.
          -Tree @{ Title; OpenTo = 's'; Root = node } a collapsible,
                    searchable hierarchy above the tables. Node: @{ l = 't'|
                    'm'|'s'|'g'|'r'|'d' (tenant, management group,
                    subscription, resource group, resource, deleted
                    resources); n = name; d = detail;
                    c = @(@(count, 'noun'), ...); f = @{ table; filters }
                    (clicking the name filters that table); k = children;
                    x = flagged (e.g. an empty resource group); p = a 0-100
                    score, drawn as a coloured pill; h, m, lo = high,
                    medium and low findings, as red, amber and blue pills;
                    co = a cost, drawn as a green pill (amber with cx), with
                    ct as its tooltip }. Children
                    are drawn when a node is opened, so big trees stay fast.
 
        Every table can be searched, filtered, sorted (click a header),
        grouped (with subtotals) and downloaded as CSV - the rows shown, all
        of them. Light and dark themes follow the browser, with a switch.
        Rows are shown 200 at a time, so large tables stay fast.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [string] $Path,

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

        [string] $Subtitle,

        [System.Collections.IDictionary] $Fact,

        [object[]] $Tile = @(),

        [object[]] $Chart = @(),

        [object[]] $Table = @(),

        [object[]] $Notice = @(),

        [System.Collections.IDictionary] $Tree,

        [object[]] $Tab = @()
    )

    $fullPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)

    # Keys in camelCase for the page; everything else passes through.
    $camel = {
        param([System.Collections.IDictionary] $Source)
        $out = [ordered]@{}
        foreach ($key in $Source.Keys) {
            $name = [string]$key
            $out[$name.Substring(0, 1).ToLowerInvariant() + $name.Substring(1)] = $Source[$key]
        }
        $out
    }

    $session = $script:AACSession
    $facts = [System.Collections.Generic.List[object]]::new()
    if ($session) {
        $facts.Add([ordered]@{ k = 'Azure account'; v = [string]$session.Account })
        $facts.Add([ordered]@{ k = 'Tenant'; v = [string]$session.TenantId })
    }
    if ($Fact) {
        foreach ($key in $Fact.Keys) { $facts.Add([ordered]@{ k = [string]$key; v = [string]$Fact[$key] }) }
    }
    $facts.Add([ordered]@{ k = 'Computer'; v = "$([Environment]::MachineName) · PowerShell $($PSVersionTable.PSVersion)" })

    $tables = foreach ($item in $Table) {
        $t = & $camel $item
        $t['columns'] = @(foreach ($column in @($item.Columns)) { & $camel $column })
        # Rows: only the columns' properties, as plain values.
        $keys = @($t['columns'] | ForEach-Object { $_['key'] })
        $idKeys = @($t['columns'] | ForEach-Object { $_['idKey'], $_['href'], $_['currencyKey'] } | Where-Object { $_ })
        $keys = @($keys + $idKeys + 'ResourceId' | Select-Object -Unique)
        $t['rows'] = @(foreach ($row in @($item.Rows)) {
                $plain = [ordered]@{}
                foreach ($key in $keys) {
                    $value = if ($row -is [System.Collections.IDictionary]) { $row[$key] } else { Get-AACPropertyValue -InputObject $row -Name $key }
                    if ($value -is [datetime]) { $value = $value.ToString('o', [cultureinfo]::InvariantCulture) }
                    elseif ($null -ne $value -and $value -isnot [string] -and $value -isnot [ValueType]) { $value = [string]$value }
                    $plain[$key] = $value
                }
                $plain
            })
        if ($item.Contains('Sort')) { $t['sort'] = & $camel $item.Sort }
        $t
    }

    $moduleInfo = $MyInvocation.MyCommand.Module
    $model = [ordered]@{
        title         = $Title
        subtitle      = $Subtitle
        generated     = [datetime]::UtcNow.ToString('o', [cultureinfo]::InvariantCulture)
        account       = if ($session) { [string]$session.Account } else { '' }
        tenantId      = if ($session) { [string]$session.TenantId } else { '' }
        moduleVersion = if ($moduleInfo) { "v$($moduleInfo.Version)" } else { '' }
        environment   = "PowerShell $($PSVersionTable.PSVersion)"
        facts         = $facts.ToArray()
        notices       = @(foreach ($item in $Notice) { & $camel $item })
        tiles         = @(foreach ($item in $Tile) { & $camel $item })
        charts        = @(foreach ($item in $Chart) {
                $c = & $camel $item
                $c['items'] = @(foreach ($bar in @($item.Items)) { & $camel $bar })
                $c
            })
        tables        = @($tables)
    }
    if ($Tab.Count) { $model['tabs'] = @(foreach ($item in $Tab) { & $camel $item }) }
    if ($Tree) {
        $model['tree'] = [ordered]@{ title = $Tree.Title; openTo = $(if ($Tree.Contains('OpenTo')) { $Tree.OpenTo } else { 's' }); root = $Tree.Root }
    }

    # EscapeHtml turns < > & ' into \u escapes, so no value can close the
    # <script> element the data sits in.
    $json = ConvertTo-Json -InputObject $model -Depth 40 -Compress -EscapeHandling EscapeHtml
    $template = [System.IO.File]::ReadAllText((Join-Path -Path $script:AACModuleRoot -ChildPath 'Private/DataReport.html'))
    $html = $template.Replace('__AAC_TITLE__', [System.Net.WebUtility]::HtmlEncode($Title)).Replace('__AAC_DATA__', $json)

    $folder = Split-Path -Path $fullPath -Parent
    if ($folder -and -not (Test-Path -LiteralPath $folder)) {
        New-Item -ItemType Directory -Path $folder -Force | Out-Null
    }
    [System.IO.File]::WriteAllText($fullPath, $html, [System.Text.UTF8Encoding]::new($false))
    Get-Item -LiteralPath $fullPath
}