Modules/Private/Reporting/Export-AZSCPowerBIReport.ps1
|
<#
.Synopsis Export inventory data as a Power BI-ready CSV bundle .DESCRIPTION Reads all cache files produced by the processing phase and exports them as a folder of flat CSV files optimized for Power BI / Microsoft Fabric import. Each inventory module becomes its own CSV file with consistent naming. A _metadata.csv provides tenant/scan context, a Subscriptions.csv dimension table enables slicing, and a _relationships.json manifest describes the star-schema relationships for automatic Power BI data model configuration. Output structure: PowerBI/ _metadata.csv — Scan metadata (tenant, date, scope, version) _relationships.json — Table relationship definitions for Power BI Subscriptions.csv — Subscription dimension table Resources_{Module}.csv — One file per ARM inventory module Entra_{Module}.csv — One file per Entra/Identity module .PARAMETER ReportCache Path to the ReportCache folder containing processed category JSON files. .PARAMETER File Path to the base report file (e.g. the .xlsx path). The Power BI folder is created as a sibling directory named "PowerBI". .PARAMETER TenantID The Azure AD / Entra ID tenant identifier. .PARAMETER Subscriptions Array of subscription objects with Id and Name properties. .PARAMETER Scope Scan scope: All, ArmOnly, or EntraOnly. .OUTPUTS [string] Path to the PowerBI output folder. .LINK https://github.com/thisismydemo/azure-scout/Modules/Private/Reporting/Export-AZSCPowerBIReport.ps1 .COMPONENT This PowerShell Module is part of Azure Scout (AZSC) .NOTES Version: 1.0.0 First Release Date: February 25, 2026 Authors: AzureScout Contributors #> function Export-AZSCPowerBIReport { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ReportCache, [Parameter(Mandatory)] [string]$File, [Parameter()] [string]$TenantID, [Parameter()] [object]$Subscriptions, [Parameter()] [ValidateSet('All', 'ArmOnly', 'EntraOnly')] [string]$Scope = 'All' ) Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Starting Power BI CSV export.') # ── Resolve output folder ──────────────────────────────────────────── $BaseDir = Split-Path $File -Parent $PowerBIDir = Join-Path $BaseDir 'PowerBI' if (Test-Path $PowerBIDir) { Remove-Item $PowerBIDir -Recurse -Force } New-Item -ItemType Directory -Path $PowerBIDir -Force | Out-Null Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + " - Power BI output folder: $PowerBIDir") # ── Helpers ────────────────────────────────────────────────────────── # Convert a module name to PascalCase display name with spaces function ConvertTo-DisplayName { param([string]$Name) # Insert space before each uppercase letter that follows a lowercase letter $spaced = [regex]::Replace($Name, '(?<=[a-z])(?=[A-Z])', ' ') # Capitalize first letter if ($spaced.Length -gt 0) { $spaced = $spaced.Substring(0,1).ToUpper() + $spaced.Substring(1) } return $spaced } # Export an array of ordered hashtables / PSObjects to CSV function Export-FlatCsv { param( [string]$FilePath, [object[]]$Data, [string]$Category, [string]$Module ) if (-not $Data -or $Data.Count -eq 0) { return 0 } # Normalize all items to PSCustomObject and add _Category / _Module columns $rows = foreach ($item in $Data) { $props = [ordered]@{ '_Category' = $Category '_Module' = $Module } if ($item -is [System.Collections.IDictionary]) { foreach ($key in $item.Keys) { $value = $item[$key] # Flatten arrays/objects to strings for CSV compatibility if ($null -eq $value) { $props[$key] = '' } elseif ($value -is [array]) { $props[$key] = ($value -join '; ') } elseif ($value -is [PSCustomObject] -or $value -is [System.Collections.IDictionary]) { $props[$key] = ($value | ConvertTo-Json -Depth 5 -Compress) } elseif ($value -is [bool]) { $props[$key] = $value.ToString() } else { $props[$key] = $value } } } else { foreach ($prop in $item.PSObject.Properties) { $value = $prop.Value if ($null -eq $value) { $props[$prop.Name] = '' } elseif ($value -is [array]) { $props[$prop.Name] = ($value -join '; ') } elseif ($value -is [PSCustomObject] -or $value -is [System.Collections.IDictionary]) { $props[$prop.Name] = ($value | ConvertTo-Json -Depth 5 -Compress) } elseif ($value -is [bool]) { $props[$prop.Name] = $value.ToString() } else { $props[$prop.Name] = $value } } } [PSCustomObject]$props } $rows | Export-Csv -Path $FilePath -NoTypeInformation -Encoding UTF8 -Force return $rows.Count } # ── Track generated files for summary ──────────────────────────────── $generatedFiles = [System.Collections.Generic.List[PSCustomObject]]::new() $totalRows = 0 # ── 1. Metadata CSV ────────────────────────────────────────────────── $metadataFile = Join-Path $PowerBIDir '_metadata.csv' $subCount = if ($Subscriptions) { @($Subscriptions).Count } else { 0 } $metadataRows = @( [ordered]@{ Property = 'Tool'; Value = 'AzureScout' } [ordered]@{ Property = 'Version'; Value = '1.0.0' } [ordered]@{ Property = 'TenantId'; Value = $TenantID } [ordered]@{ Property = 'Subscriptions'; Value = $subCount } [ordered]@{ Property = 'GeneratedAt'; Value = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') } [ordered]@{ Property = 'Scope'; Value = $Scope } ) $metadataRows | ForEach-Object { [PSCustomObject]$_ } | Export-Csv -Path $metadataFile -NoTypeInformation -Encoding UTF8 -Force $generatedFiles.Add([PSCustomObject]@{ File = '_metadata.csv'; Category = 'Metadata'; Rows = $metadataRows.Count }) Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Metadata CSV written.') # ── 2. Subscriptions dimension table ───────────────────────────────── $subsFile = Join-Path $PowerBIDir 'Subscriptions.csv' if ($Subscriptions -and @($Subscriptions).Count -gt 0) { $subRows = foreach ($sub in $Subscriptions) { [PSCustomObject][ordered]@{ SubscriptionId = if ($sub.Id) { $sub.Id } elseif ($sub.SubscriptionId) { $sub.SubscriptionId } else { '' } SubscriptionName = if ($sub.Name) { $sub.Name } else { '' } } } $subRows | Export-Csv -Path $subsFile -NoTypeInformation -Encoding UTF8 -Force $generatedFiles.Add([PSCustomObject]@{ File = 'Subscriptions.csv'; Category = 'Dimension'; Rows = @($subRows).Count }) } else { # Write empty dimension table with headers [PSCustomObject]@{ SubscriptionId = ''; SubscriptionName = '' } | Export-Csv -Path $subsFile -NoTypeInformation -Encoding UTF8 -Force $generatedFiles.Add([PSCustomObject]@{ File = 'Subscriptions.csv'; Category = 'Dimension'; Rows = 0 }) } Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Subscriptions CSV written.') # ── 3. Discover inventory module folders ───────────────────────────── $ParentPath = (Get-Item $PSScriptRoot).Parent.Parent $InventoryModulesPath = Join-Path $ParentPath 'Public' 'InventoryModules' $ModuleFolders = Get-ChildItem -Path $InventoryModulesPath -Directory -ErrorAction SilentlyContinue $EntraFolders = @('Identity') # Build module name lookup: lowercase → actual BaseName $ModuleMap = @{} if ($ModuleFolders) { foreach ($folder in $ModuleFolders) { foreach ($mod in (Get-ChildItem $folder.FullName -Filter '*.ps1' -ErrorAction SilentlyContinue)) { $ModuleMap[$mod.BaseName.ToLower()] = $mod.BaseName } } } # Helper: PascalCase fallback function ConvertTo-PascalCase { param([string]$Name) if ([string]::IsNullOrEmpty($Name)) { return $Name } return $Name.Substring(0,1).ToUpper() + $Name.Substring(1) } # ── 4. Read cache files and export CSVs ────────────────────────────── $CacheFiles = Get-ChildItem -Path $ReportCache -Recurse -Filter '*.json' -ErrorAction SilentlyContinue # Track all relationship sources for manifest $relationshipTables = [System.Collections.Generic.List[string]]::new() foreach ($ModuleFolder in $ModuleFolders) { $FolderName = $ModuleFolder.Name $JSONFileName = "$FolderName.json" $CacheFile = $CacheFiles | Where-Object { $_.Name -eq $JSONFileName } if (-not $CacheFile) { Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + " - No cache file for $FolderName — skipping.") continue } $Reader = New-Object System.IO.StreamReader($CacheFile.FullName) $RawJson = $Reader.ReadToEnd() $Reader.Dispose() if ([string]::IsNullOrWhiteSpace($RawJson)) { continue } $CacheData = $RawJson | ConvertFrom-Json $ModulePath = Join-Path $ModuleFolder.FullName '*.ps1' $ModuleFiles = Get-ChildItem -Path $ModulePath -ErrorAction SilentlyContinue $isEntra = $FolderName -in $EntraFolders foreach ($Module in $ModuleFiles) { $ModName = $Module.BaseName $ModResources = $CacheData.$ModName if (-not $ModResources -or @($ModResources).Count -eq 0) { continue } # Build filename: Resources_ModuleName.csv or Entra_ModuleName.csv $prefix = if ($isEntra) { 'Entra' } else { 'Resources' } $csvName = "${prefix}_${ModName}.csv" $csvPath = Join-Path $PowerBIDir $csvName $categoryDisplay = if ($isEntra) { 'Identity' } else { $FolderName } $rowCount = Export-FlatCsv -FilePath $csvPath -Data @($ModResources) -Category $categoryDisplay -Module $ModName if ($rowCount -gt 0) { $generatedFiles.Add([PSCustomObject]@{ File = $csvName; Category = $categoryDisplay; Rows = $rowCount }) $totalRows += $rowCount $relationshipTables.Add($csvName) Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + " - Exported $rowCount rows → $csvName") } } } # ── 5. Relationships manifest ──────────────────────────────────────── $relationshipsFile = Join-Path $PowerBIDir '_relationships.json' # Build star-schema relationships: each resource table links to Subscriptions via Subscription $relationships = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($table in $relationshipTables) { # Resource tables that have a Subscription column can join to Subscriptions dimension $relationships.Add([PSCustomObject][ordered]@{ fromTable = [System.IO.Path]::GetFileNameWithoutExtension($table) fromColumn = 'Subscription' toTable = 'Subscriptions' toColumn = 'SubscriptionName' type = 'many-to-one' }) } $manifest = [ordered]@{ description = 'Power BI data model relationships for Azure Scout inventory data' generatedAt = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ') instructions = 'In Power BI Desktop: Get Data > Folder > select the PowerBI directory. Use these relationships to configure the data model.' tables = @(@('_metadata.csv', 'Subscriptions.csv') + @($relationshipTables)) relationships = @($relationships) } $manifest | ConvertTo-Json -Depth 10 | Out-File -FilePath $relationshipsFile -Encoding UTF8 -Force Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Relationships manifest written.') # ── 6. Summary ─────────────────────────────────────────────────────── $fileCount = $generatedFiles.Count Write-Host "Power BI CSV bundle saved to: " -ForegroundColor Green -NoNewline Write-Host $PowerBIDir -ForegroundColor Cyan Write-Host " Files: $fileCount | Total rows: $totalRows" -ForegroundColor Gray Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + " - Power BI export complete: $fileCount files, $totalRows rows.") return $PowerBIDir } |