Private/Write-AACAssessmentHtml.ps1
|
function Write-AACAssessmentHtml { <# .SYNOPSIS Writes the Invoke-AACAssessment report as one interactive HTML page. .DESCRIPTION Tiles (subscriptions, resource groups, resources, types, locations, Advisor High, retirements, Defender High, policy non-compliance, outages, unattached resources, untagged resources, cost - each opening its table), charts (resources by category, location, subscription and type; Advisor by category; Defender by severity - a click filters the table), the tenant's tree (management groups > subscriptions > resource groups, with their resource counts), and a table per sheet under its category, listed in the contents - each collapsible, searchable, filterable, groupable and downloadable as CSV. #> [CmdletBinding()] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory)] [hashtable] $Assessment, [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Title, [System.Collections.IDictionary] $Detail ) $stats = $Assessment.Stats $sheets = @($Assessment.Sheets) $find = { param([string] $Name) @($sheets | Where-Object Sheet -EQ $Name) | Select-Object -First 1 } $idOf = { param([string] $Name) $s = & $find $Name; if ($s) { $s.Id } else { '' } } $tones = @{ High = 'bad'; Medium = 'warn'; Low = 'info'; Critical = 'bad' Yes = 'warn'; No = 'neutral'; Enabled = 'good'; Disabled = 'neutral'; Warned = 'warn'; Active = 'warn'; Resolved = 'good' Deny = 'bad'; Audit = 'warn'; AuditIfNotExists = 'warn'; DeployIfNotExists = 'info'; Modify = 'info' } $tile = { param($Value, [string] $Label, [string] $Tone, [string] $Sheet, [hashtable] $Filters) $t = @{ Value = $Value; Label = $Label; Tone = $Tone } $id = & $idOf $Sheet if ($id) { $t.Table = $id; if ($Filters) { $t.Filters = $Filters } } $t } $tiles = @( & $tile ('{0:N0}' -f $stats.Subscriptions) 'subscriptions' 'info' 'Subscriptions' & $tile ('{0:N0}' -f $stats.ResourceGroups) "resource groups$(if ($stats.EmptyResourceGroups) { " ($($stats.EmptyResourceGroups) empty)" })" 'neutral' 'Resource groups' & $tile ('{0:N0}' -f $stats.Resources) 'resources' 'info' 'All resources' & $tile ('{0:N0}' -f $stats.ResourceTypes) "resource types in $($stats.Locations) location(s)" 'violet' 'Resource types' if ($null -ne $stats.Advisor) { & $tile ('{0:N0}' -f $stats.AdvisorHigh) 'Advisor High-impact recommendations' $(if ($stats.AdvisorHigh) { 'bad' } else { 'good' }) 'Advisor recommendations' @{ Impact = 'High' } } & $tile ('{0:N0}' -f $stats.Retirements) 'resources using retiring features' $(if ($stats.Retirements) { 'warn' } else { 'good' }) 'Retirements' if ($null -ne $stats.Security) { & $tile ('{0:N0}' -f $stats.SecurityHigh) 'Defender High-severity recommendations' $(if ($stats.SecurityHigh) { 'bad' } else { 'good' }) 'Security recommendations' @{ Severity = 'High' } } if (& $find 'Policy compliance') { & $tile ('{0:N0}' -f $stats.PolicyNonCompliant) 'non-compliant policy states' $(if ($stats.PolicyNonCompliant) { 'warn' } else { 'good' }) 'Policy compliance' } if (& $find 'Outages') { & $tile ('{0:N0}' -f $stats.Outages) 'Azure outages in 6 months' $(if ($stats.Outages) { 'warn' } else { 'good' }) 'Outages' } & $tile ('{0:N0}' -f $stats.Orphans) 'unattached or empty resources' $(if ($stats.Orphans) { 'warn' } else { 'good' }) '' & $tile ('{0:N0}' -f $stats.Untagged) 'resources without tags' $(if ($stats.Untagged) { 'warn' } else { 'good' }) 'All resources' @{ Tagged = 'No' } if ($null -ne $stats.CostMonthToDate) { & $tile ('{0:N2} {1}' -f $stats.CostMonthToDate, $stats.Currency) "cost this month (last month $('{0:N2}' -f $stats.CostLastMonth))" 'good' 'Subscriptions' } ) $all = & $find 'All resources' $rows = @(if ($all) { $all.Rows }) $bars = { param([string] $Property, [int] $Top) @($rows | Group-Object -Property $Property | Where-Object Name | Sort-Object Count -Descending | Select-Object -First $Top | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }) } $charts = [System.Collections.Generic.List[hashtable]]::new() if ($rows.Count) { $charts.Add(@{ Title = 'Resources by category'; Kind = 'donut'; CenterLabel = 'resources'; Items = & $bars 'Category' 12; Table = $all.Id; Column = 'Category' }) $charts.Add(@{ Title = 'Resources by location'; Items = & $bars 'Location' 12; Table = $all.Id; Column = 'Location'; Tone = 'info' }) $charts.Add(@{ Title = 'Resources by subscription'; Items = & $bars 'Subscription' 12; Table = $all.Id; Column = 'Subscription'; Tone = 'violet' }) $charts.Add(@{ Title = 'Most common resource types'; Items = & $bars 'Type' 15; Table = $all.Id; Column = 'Type'; Wide = $true }) } $advisor = & $find 'Advisor recommendations' if ($advisor -and @($advisor.Rows).Count) { $charts.Add(@{ Title = 'Advisor recommendations by category'; Items = @($advisor.Rows | Group-Object Category | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = $advisor.Id; Column = 'Category'; Tone = 'warn' }) } $security = & $find 'Security recommendations' if ($security -and @($security.Rows).Count) { $charts.Add(@{ Title = 'Defender recommendations by severity'; Items = @(foreach ($level in 'High', 'Medium', 'Low') { $n = @($security.Rows | Where-Object Severity -EQ $level).Count; if ($n) { @{ Label = $level; Value = $n; Tone = $tones[$level]; Filter = $level } } }); Table = $security.Id; Column = 'Severity' }) } $tables = foreach ($sheet in $sheets) { if (-not @($sheet.Rows).Count -and -not $sheet.Error) { continue } $count = @($sheet.Rows).Count $columns = foreach ($label in $sheet.Columns) { $type = [string]$sheet.Types[$label] $column = @{ Key = $label; Label = $label } switch ($type) { 'number' { $column.Type = 'number'; $column.Format = 'N0'; if ($label -match 'GB|%|Memory|Score|Savings|Cost|Quantity') { $column.Format = 'N2' } } 'money' { $column.Type = 'money'; $column.Sum = $true; if ($sheet.Columns -contains 'Currency') { $column.CurrencyKey = 'Currency' } } 'score' { $column.Type = 'score' } 'badge' { $column.Type = 'badge'; $column.Tones = $tones; $column.Facet = $true } 'resource' { $column.Type = 'resource'; $column.IdKey = 'ResourceId' } 'wide' { $column.Type = 'wide' } 'mono' { $column.Type = 'mono' } } if ($label -in 'Orphaned', 'Empty') { $column.Type = 'badge'; $column.Tones = @{ Yes = 'warn'; No = 'good' } } if (-not $column.Contains('Facet') -and $type -in 'text', 'badge', 'mono' -and $count -gt 5) { # A filter drop-down where there are few distinct values. $distinct = @($sheet.Rows | ForEach-Object { $_.$label } | Where-Object { $null -ne $_ -and "$_" -ne '' } | Select-Object -Unique -First 31).Count if ($label -in 'Subscription', 'Resource group', 'Location', 'Category', 'Type', 'Kind' -or ($distinct -gt 1 -and $distinct -le 30)) { $column.Facet = $true } } if ($label -in 'Tags') { $column.Type = 'wide' } $column } $groupBy = @($sheet.Columns | Where-Object { $_ -in 'Subscription', 'Resource group', 'Location', 'Category', 'Impact', 'Severity', 'Kind', 'SKU', 'Size', 'State', 'Status' }) $note = @($sheet.Note, $(if ($sheet.Error) { "Couldn't be read: $($sheet.Error)" })) | Where-Object { $_ } @{ Id = $sheet.Id; Title = $sheet.Sheet; Section = $sheet.Category; Noun = 'rows'; File = $sheet.Id; Rows = @($sheet.Rows) Columns = @($columns) + @(@{ Key = 'ResourceId'; Label = 'Resource ID'; Hidden = $true }) Note = ($note -join ' ') GroupBy = $groupBy } } $notices = @(foreach ($line in @($Assessment.Notices)) { @{ Tone = 'warn'; Text = $line } }) $tree = @{ Title = 'Tenant'; OpenTo = 'm'; Root = $Assessment.Tree } Write-AACHtmlReport -Path $Path -Title $Title -Subtitle 'Azure environment assessment: inventory, organization, Advisor, security, policy, health and cost' -Fact $Detail -Tile $tiles -Chart $charts.ToArray() -Table @($tables) -Notice $notices -Tree $tree } |