Public/Show-AACResource.ps1
|
function Show-AACResource { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Shows how many Azure resources you have - by type, location, resource group or subscription - as a colourful Spectre.Console bar chart. .DESCRIPTION Counts every resource the signed-in account can see (or only those in -SubscriptionId, of -ResourceType) with Azure Resource Graph, over REST with the Connect-AAC sign-in - two queries however many resources there are, and no Az modules. The view: ── Azure Admin Console :: Azure resources ───────────────────── account · tenant · scope · when [ resources ] [ types ] [ subscriptions ] [ resource groups ] [ locations ] Resources by type (top 20) compute/virtualmachines ████████████████████████ 412 network/networkinterfaces ███████████████████ 398 storage/storageaccounts ██████████ 164 ... 37 other types ████ 61 Each bar has its own colour. -By picks what is counted: resource Type (the default), Location, ResourceGroup or Subscription. -Top sets how many bars are drawn; the rest are summed in a final grey bar. When the view is longer than the terminal it is paged: press any key for the next page, or A for the rest. -NoPaging turns that off. With -PassThru the counts are also returned as AAC.ResourceCount objects (Name, Count, Share), every one of them, not only the top. -HtmlPath writes a self-contained, interactive inventory instead of the view: every resource (name, type, resource group, location, subscription, kind, SKU, tags) with Azure portal links, tiles and charts by type, location, subscription and resource group that filter it, search, filters, grouping and a CSV download. It needs one more Resource Graph query, for the resources themselves. With -HtmlPath the console shows only the progress and the file written. .PARAMETER SubscriptionId Only count resources in these subscriptions. Defaults to every subscription the signed-in account can see. .PARAMETER By What to count by: Type (default), Location, ResourceGroup or Subscription. .PARAMETER ResourceType Only count resources of these types. Case-insensitive; wildcards work, e.g. 'microsoft.network/*'. .PARAMETER Top How many bars to draw (default 20); the rest are summed in one bar. .PARAMETER HtmlPath Write an interactive HTML inventory of the resources to this file instead of showing the view. An existing file is overwritten; missing folders are created. .PARAMETER Title The HTML report's title. Defaults to 'Azure resources'. .PARAMETER NoPaging Show the whole view at once instead of a screen at a time. .PARAMETER PassThru Also return the counts as AAC.ResourceCount objects. .EXAMPLE Connect-AAC Show-AACResource The top 20 resource types across every subscription you can see. .EXAMPLE Show-AACResource -By Location How many resources are in each Azure region. .EXAMPLE Show-AACResource -ResourceType 'microsoft.network/*' -Top 10 The ten most common networking resource types. .EXAMPLE Show-AACResource -By Subscription -PassThru | Export-Csv .\ResourcesPerSubscription.csv -NoTypeInformation Shows resources per subscription and saves the counts as a CSV file. .EXAMPLE Show-AACResource -HtmlPath .\out\Inventory.html Every resource in an interactive HTML inventory, with portal links. .OUTPUTS AAC.ResourceCount (with -PassThru) #> [CmdletBinding()] [OutputType('AAC.ResourceCount')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [ValidateSet('Type', 'Location', 'ResourceGroup', 'Subscription')] [string] $By = 'Type', # Letters, digits, '.', '/', '-', '_' and '*' only: the patterns go # into a Resource Graph query. [ValidatePattern('^[\w.*/-]+$')] [SupportsWildcards()] [string[]] $ResourceType, [ValidateRange(1, 100)] [int] $Top = 20, [string] $HtmlPath, [string] $Title = 'Azure resources', [switch] $NoPaging, [switch] $PassThru ) # Resolve the path now, relative to the caller's location, so a bad path # fails before any Azure call. $htmlFullPath = if ($HtmlPath) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($HtmlPath) } $headers = @{ Authorization = "Bearer $(Get-AACAccessToken)" } # Wildcards -> one KQL regular expression over the lower-case type. $filter = if ($ResourceType) { $patterns = @($ResourceType | ForEach-Object { '^' + ([regex]::Escape($_.ToLowerInvariant()) -replace '\\\*', '.*') + '$' }) "| where tolower(type) matches regex @'$($patterns -join '|')'" } $groupBy = @{ Type = 'name = tolower(type)' Location = 'name = tolower(location)' ResourceGroup = 'subscriptionId, name = tolower(resourceGroup)' Subscription = 'name = subscriptionId' }[$By] # The title first, then a line per step - as every command shows them. Write-AACRule -Title 'Azure Admin Console :: Azure resources' -Color 'deepskyblue3_1' $data = Invoke-AACProgress -ScriptBlock { Update-AACProgress -Id 'read' -Total $(if ($HtmlPath) { 4 } else { 3 }) -Description 'Counting resources in Azure Resource Graph' $countRows = @(Invoke-AACResourceGraphQuery -SubscriptionId $SubscriptionId -Headers $headers -Query "Resources $filter | summarize n = count() by $groupBy") Update-AACProgress -Id 'read' -Increment 1 -Description 'Counting types, locations and resource groups' $totalRows = @(Invoke-AACResourceGraphQuery -SubscriptionId $SubscriptionId -Headers $headers -Query "Resources $filter | summarize resources = count(), types = dcount(tolower(type)), locations = dcount(tolower(location)), groups = dcount(strcat(subscriptionId, '/', tolower(resourceGroup))), subscriptions = dcount(subscriptionId)") Update-AACProgress -Id 'read' -Increment 1 -Description 'Reading subscription names' $subscriptionRows = @(Invoke-AACResourceGraphQuery -Headers $headers -Query "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name") # The HTML inventory lists the resources themselves. $inventoryRows = @() if ($HtmlPath) { Update-AACProgress -Id 'read' -Increment 1 -Description 'Reading the resources for the inventory' $inventoryRows = @(Invoke-AACResourceGraphQuery -SubscriptionId $SubscriptionId -Headers $headers -Query "Resources $filter | project id, name, type = tolower(type), location = tolower(location), resourceGroup, subscriptionId, kind, sku = tostring(sku.name), tags = tostring(tags) | order by name asc") } $resourceTotal = 0 foreach ($row in $countRows) { $resourceTotal += [int]$row.n } Update-AACProgress -Id 'read' -Complete -Description ('Counted {0:N0} resource(s) in {1:N0} group(s) by {2}' -f $resourceTotal, $countRows.Count, $By.ToLowerInvariant()) @{ Counts = $countRows; Totals = $totalRows; Subscriptions = $subscriptionRows; Inventory = $inventoryRows } } $subscriptionNames = @{} foreach ($subscription in $data.Subscriptions) { $subscriptionNames[$subscription.subscriptionId] = $subscription.name } $label = { param($Row) switch ($By) { 'Type' { ([string]$Row.name) -replace '^microsoft\.', '' } 'Location' { if ($Row.name) { [string]$Row.name } else { '(none)' } } 'ResourceGroup' { "$($Row.name) ($(if ($subscriptionNames[$Row.subscriptionId]) { $subscriptionNames[$Row.subscriptionId] } else { $Row.subscriptionId }))" } 'Subscription' { if ($subscriptionNames[[string]$Row.name]) { $subscriptionNames[[string]$Row.name] } else { [string]$Row.name } } } } # Measure-Object returns nothing at all for an empty list, so add up by hand. $total = 0.0 foreach ($row in $data.Counts) { $total += [double]$row.n } $counts = @($data.Counts | Sort-Object -Property @{ Expression = { [int]$_.n }; Descending = $true }, name | ForEach-Object { [pscustomobject]@{ PSTypeName = 'AAC.ResourceCount' By = $By Name = & $label $_ Count = [int]$_.n Share = if ($total) { [Math]::Round(100 * $_.n / $total, 1) } else { 0 } } }) # --- The HTML inventory, or the view ---------------------------------------------------- if ($HtmlPath) { $inventory = @(foreach ($row in $data.Inventory) { $tags = '' if ($row.tags -and $row.tags -ne '{}' -and $row.tags -ne 'null') { try { $parsed = ConvertFrom-Json -InputObject $row.tags -AsHashtable -ErrorAction Stop $tags = (@($parsed.Keys | Sort-Object | ForEach-Object { "$_=$($parsed[$_])" }) -join '; ') } catch { $tags = [string]$row.tags } } [pscustomobject]@{ Name = [string]$row.name Type = [string]$row.type ResourceGroup = [string]$row.resourceGroup Location = [string]$row.location SubscriptionName = $(if ($subscriptionNames[$row.subscriptionId]) { $subscriptionNames[$row.subscriptionId] } else { [string]$row.subscriptionId }) SubscriptionId = [string]$row.subscriptionId Kind = [string]$row.kind Sku = [string]$row.sku Tags = $tags ResourceId = [string]$row.id } }) $scope = [ordered]@{ Subscriptions = if ($SubscriptionId) { $SubscriptionId -join ', ' } else { 'every subscription the account can see' } } if ($ResourceType) { $scope['Resource types'] = $ResourceType -join ', ' } $null = Invoke-AACExport -HtmlPath $htmlFullPath -WriteHtml { Write-AACResourceHtml -Resource $inventory -Path $htmlFullPath -Title $Title -Detail $scope } } else { Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock { $escape = { param($Text) [Spectre.Console.Markup]::Escape([string]$Text) } # Unicode symbols, or ASCII in a console that isn't UTF-8. $glyph = Get-AACGlyph $facts = [System.Collections.Generic.List[string]]::new() if ($script:AACSession) { $facts.Add("[white]$(& $escape $script:AACSession.Account)[/]") $facts.Add("tenant $(& $escape $script:AACSession.TenantId)") } $facts.Add($(if ($SubscriptionId) { "$($SubscriptionId.Count) subscription(s)" } else { 'all subscriptions' })) if ($ResourceType) { $facts.Add("types: $(& $escape ($ResourceType -join ', '))") } $facts.Add((Get-Date).ToString('d MMM yyyy HH:mm')) Write-AACMarkup "[grey58]$($facts -join " $($glyph.Dot) ")[/]" [Spectre.Console.AnsiConsole]::WriteLine() if ($counts.Count -eq 0) { Show-AACPanel -Content '[bold]No resources[/] [grey58]were found for this account and these filters.[/]' -BorderColor 'grey50' -AllowMarkup } else { $totals = $data.Totals | Select-Object -First 1 Show-AACTileRow -Tile @( @{ Value = '{0:N0}' -f [int]$totals.resources; Caption = 'resources'; Color = 'deepskyblue1' } @{ Value = '{0:N0}' -f [int]$totals.types; Caption = 'resource types'; Color = 'mediumpurple2' } @{ Value = '{0:N0}' -f [int]$totals.subscriptions; Caption = 'subscriptions'; Color = 'springgreen2' } @{ Value = '{0:N0}' -f [int]$totals.groups; Caption = 'resource groups'; Color = 'gold1' } @{ Value = '{0:N0}' -f [int]$totals.locations; Caption = 'locations'; Color = 'hotpink' } ) [Spectre.Console.AnsiConsole]::WriteLine() $noun = @{ Type = 'type'; Location = 'location'; ResourceGroup = 'resource group'; Subscription = 'subscription' }[$By] $bars = @($counts | Select-Object -First $Top | ForEach-Object { @{ Label = $_.Name; Value = $_.Count } }) $rest = @($counts | Select-Object -Skip $Top) if ($rest.Count -gt 0) { $bars += @{ Label = "$($rest.Count) other $noun$(if ($rest.Count -ne 1) { 's' })"; Value = ($rest | Measure-Object -Property Count -Sum).Sum; Color = 'grey50' } } $title = "Resources by $noun$(if ($counts.Count -gt $Top) { " (top $Top of $($counts.Count))" })" Show-AACBarChart -Item $bars -Title $title [Spectre.Console.AnsiConsole]::WriteLine() Write-AACMarkup '[grey42]-By Type|Location|ResourceGroup|Subscription picks what is counted; -PassThru returns the counts.[/]' } } } if ($PassThru) { $counts } } |