diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1
new file mode 100644
index 000000000..69f8b157b
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1
@@ -0,0 +1,71 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# FINOPSMULTITOOL.PSM1
+# MODULE LOADER FOR STANDALONE (NON-GUI) USE
+###########################################################################
+# Purpose: Dot-sources all helpers and analysis modules so they can be
+# imported via Import-Module without launching the WPF GUI.
+#
+# Usage:
+# Import-Module .\FinOpsMultitool.psm1
+# $results = Get-OrphanedResources -Subscriptions $subs -TenantId $tid
+#
+# The GUI entry point remains Start-FinOpsMultitool.ps1 (unchanged).
+###########################################################################
+
+# -- Ensure required Az modules are loaded ---------------------------------
+# Some terminals have incomplete PSModulePath — add standard user module paths
+$userModDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Modules'
+if ($userModDir -and (Test-Path $userModDir) -and $env:PSModulePath -notlike "*$userModDir*") {
+ $env:PSModulePath = "$userModDir;$env:PSModulePath"
+}
+$userModDir5 = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'WindowsPowerShell\Modules'
+if ($userModDir5 -and (Test-Path $userModDir5) -and $env:PSModulePath -notlike "*$userModDir5*") {
+ $env:PSModulePath = "$userModDir5;$env:PSModulePath"
+}
+
+foreach ($azMod in @('Az.Accounts', 'Az.Storage', 'Az.ResourceGraph')) {
+ if (-not (Get-Module $azMod)) {
+ Import-Module $azMod -ErrorAction SilentlyContinue
+ }
+}
+
+# -- Helpers (runspace pool, REST retry, ARG wrapper, MG-scope state) ----
+$helpersPath = Join-Path $PSScriptRoot 'modules\helpers'
+. (Join-Path $helpersPath 'Get-PlainAccessToken.ps1')
+. (Join-Path $helpersPath 'Invoke-AzRestMethodWithRetry.ps1')
+. (Join-Path $helpersPath 'Search-AzGraphSafe.ps1')
+. (Join-Path $helpersPath 'MgCostScope.ps1')
+. (Join-Path $helpersPath 'Read-FinOpsHubData.ps1')
+
+# -- Set script-scope root (some modules reference $script:ScriptRootDir) -
+$script:ScriptRootDir = $PSScriptRoot
+
+# -- Analysis Modules ----------------------------------------------------
+$modulePath = Join-Path $PSScriptRoot 'modules'
+. (Join-Path $modulePath 'Initialize-Scanner.ps1')
+. (Join-Path $modulePath 'Get-TenantHierarchy.ps1')
+. (Join-Path $modulePath 'Get-ContractInfo.ps1')
+. (Join-Path $modulePath 'Get-CostData.ps1')
+. (Join-Path $modulePath 'Get-ResourceCosts.ps1')
+. (Join-Path $modulePath 'Get-TagInventory.ps1')
+. (Join-Path $modulePath 'Get-CostByTag.ps1')
+. (Join-Path $modulePath 'Get-AHBOpportunities.ps1')
+. (Join-Path $modulePath 'Get-ReservationAdvice.ps1')
+. (Join-Path $modulePath 'Get-OptimizationAdvice.ps1')
+. (Join-Path $modulePath 'Get-TagRecommendations.ps1')
+. (Join-Path $modulePath 'Get-CostTrend.ps1')
+. (Join-Path $modulePath 'Deploy-ResourceTag.ps1')
+. (Join-Path $modulePath 'Get-BillingStructure.ps1')
+. (Join-Path $modulePath 'Get-CommitmentUtilization.ps1')
+. (Join-Path $modulePath 'Get-OrphanedResources.ps1')
+. (Join-Path $modulePath 'Get-BudgetStatus.ps1')
+. (Join-Path $modulePath 'Get-AnomalyAlerts.ps1')
+. (Join-Path $modulePath 'Get-SavingsRealized.ps1')
+. (Join-Path $modulePath 'Get-PolicyInventory.ps1')
+. (Join-Path $modulePath 'Get-PolicyRecommendations.ps1')
+. (Join-Path $modulePath 'Deploy-PolicyAssignment.ps1')
+. (Join-Path $modulePath 'Get-StorageTierAdvice.ps1')
+. (Join-Path $modulePath 'Get-IdleVMs.ps1')
diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1
new file mode 100644
index 000000000..074d03c24
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1
@@ -0,0 +1,2085 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# INVOKE-FINOPSMULTITOOL.PS1
+# INTERACTIVE TERMINAL LAUNCHER FOR FINOPS MULTITOOL
+###########################################################################
+# Purpose: Provides an arrow-key driven TUI for selecting and running
+# FinOps Multitool scan modules without a GUI dependency.
+#
+# Usage: Invoke-FinOpsMultitool
+# Invoke-FinOpsMultitool -SubscriptionId '2693c348-...'
+# Invoke-FinOpsMultitool -OutputPath './results'
+#
+# Requirements:
+# - PowerShell 5.1+ (Windows) or 7+ (cross-platform)
+# - Az PowerShell modules: Az.Accounts, Az.Resources, Az.ResourceGraph
+# - Azure RBAC: Reader + Cost Management Reader on target scope
+###########################################################################
+
+function Invoke-FinOpsMultitool {
+ [CmdletBinding()]
+ param(
+ [string]$SubscriptionId,
+ [string]$OutputPath
+ )
+
+ # -- Load modules (always force-reimport to pick up latest changes) ----
+ $multitoolRoot = $PSScriptRoot
+ $psm1Path = Join-Path $multitoolRoot 'FinOpsMultitool.psm1'
+ if (Test-Path $psm1Path) {
+ Import-Module $psm1Path -Force
+ }
+ else {
+ Write-Error "FinOpsMultitool.psm1 not found at $psm1Path"
+ return
+ }
+
+ # -- Pre-flight: verify required Az modules ----------------------------
+ $requiredModules = @(
+ @{ Name = 'Az.Accounts'; Reason = 'Azure authentication' }
+ @{ Name = 'Az.ResourceGraph'; Reason = 'Resource Graph queries (optimization, governance scans)' }
+ @{ Name = 'Az.Storage'; Reason = 'FinOps Hub data access (reading cost exports)' }
+ )
+ $missing = @()
+ foreach ($req in $requiredModules) {
+ if (-not (Get-Module $req.Name -ErrorAction SilentlyContinue) -and
+ -not (Get-Module $req.Name -ListAvailable -ErrorAction SilentlyContinue)) {
+ $missing += $req
+ }
+ }
+ if ($missing.Count -gt 0) {
+ Write-Host ""
+ Write-Host " MISSING REQUIRED MODULES" -ForegroundColor Red
+ Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray
+ foreach ($m in $missing) {
+ Write-Host " $($m.Name)" -ForegroundColor Red -NoNewline
+ Write-Host " — $($m.Reason)" -ForegroundColor DarkGray
+ }
+ Write-Host ""
+ Write-Host " Install with:" -ForegroundColor White
+ $names = ($missing.Name | ForEach-Object { "'$_'" }) -join ', '
+ Write-Host " Install-Module $names -Scope CurrentUser" -ForegroundColor Yellow
+ Write-Host ""
+ return
+ }
+
+ # -- Scan Module Registry ----------------------------------------------
+ $scanModules = @(
+ # -- Optimization (Resource Graph) --
+ @{ Name = 'Orphaned Resources'; Fn = 'Get-OrphanedResources'; Selected = $true; Category = 'Optimization' }
+ @{ Name = 'Idle VMs'; Fn = 'Get-IdleVMs'; Selected = $true; Category = 'Optimization' }
+ @{ Name = 'Storage Tier Advice'; Fn = 'Get-StorageTierAdvice'; Selected = $true; Category = 'Optimization' }
+ @{ Name = 'AHB Opportunities'; Fn = 'Get-AHBOpportunities'; Selected = $true; Category = 'Optimization' }
+ # -- Governance (run early — other modules depend on these) --
+ @{ Name = 'Tag Inventory'; Fn = 'Get-TagInventory'; Selected = $true; Category = 'Governance' }
+ @{ Name = 'Tag Recommendations'; Fn = 'Get-TagRecommendations'; Selected = $true; Category = 'Governance' }
+ @{ Name = 'Policy Inventory'; Fn = 'Get-PolicyInventory'; Selected = $true; Category = 'Governance' }
+ @{ Name = 'Policy Recommendations'; Fn = 'Get-PolicyRecommendations'; Selected = $true; Category = 'Governance' }
+ # -- Cost Analysis (depends on Tag Inventory for Cost by Tag) --
+ @{ Name = 'Cost Data'; Fn = 'Get-CostData'; Selected = $true; Category = 'Cost Analysis' }
+ @{ Name = 'Resource Costs'; Fn = 'Get-ResourceCosts'; Selected = $true; Category = 'Cost Analysis' }
+ @{ Name = 'Cost by Tag'; Fn = 'Get-CostByTag'; Selected = $true; Category = 'Cost Analysis' }
+ @{ Name = 'Cost Trend'; Fn = 'Get-CostTrend'; Selected = $true; Category = 'Cost Analysis' }
+ # -- Commitments --
+ @{ Name = 'Reservation Advice'; Fn = 'Get-ReservationAdvice'; Selected = $true; Category = 'Commitments' }
+ @{ Name = 'Commitment Utilization'; Fn = 'Get-CommitmentUtilization'; Selected = $true; Category = 'Commitments' }
+ @{ Name = 'Savings Realized'; Fn = 'Get-SavingsRealized'; Selected = $true; Category = 'Commitments' }
+ # -- Monitoring --
+ @{ Name = 'Budget Status'; Fn = 'Get-BudgetStatus'; Selected = $true; Category = 'Monitoring' }
+ @{ Name = 'Anomaly Alerts'; Fn = 'Get-AnomalyAlerts'; Selected = $true; Category = 'Monitoring' }
+ # -- Advisor --
+ @{ Name = 'Optimization Advice'; Fn = 'Get-OptimizationAdvice'; Selected = $true; Category = 'Advisor' }
+ # -- Account --
+ @{ Name = 'Billing Structure'; Fn = 'Get-BillingStructure'; Selected = $false; Category = 'Account' }
+ @{ Name = 'Contract Info'; Fn = 'Get-ContractInfo'; Selected = $true; Category = 'Account' }
+ )
+
+ # -- Permission Requirements per Module --------------------------------
+ # Maps each function to the Azure RBAC role(s) needed and a human-readable reason
+ $permissionInfo = @{
+ 'Get-OrphanedResources' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to query resource metadata via Azure Resource Graph.' }
+ 'Get-IdleVMs' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph + Monitor Metrics'; Reason = 'Requires Reader to query VM metadata and Monitor metrics for CPU/network utilization.' }
+ 'Get-StorageTierAdvice' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to query storage account configurations.' }
+ 'Get-AHBOpportunities' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to query VM license types.' }
+ 'Get-TagInventory' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to inventory resource tags via Resource Graph.' }
+ 'Get-TagRecommendations' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to analyze existing tags and suggest improvements.' }
+ 'Get-PolicyInventory' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Manager'; Reason = 'Requires read access to list policy assignments and definitions.' }
+ 'Get-PolicyRecommendations' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Manager'; Reason = 'Requires read access to evaluate policy coverage gaps.' }
+ 'Get-CostData' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action. Assign Cost Management Reader or Reader at the subscription or MG scope.' }
+ 'Get-ResourceCosts' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action. Assign Cost Management Reader or Reader at the subscription or MG scope.' }
+ 'Get-CostByTag' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to query cost grouped by tag dimensions.' }
+ 'Get-CostTrend' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to retrieve historical monthly cost data.' }
+ 'Get-ReservationAdvice' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Reservation Recommendations API'; Reason = 'Requires Microsoft.Consumption/reservationRecommendations/read to retrieve reservation purchase advice.' }
+ 'Get-CommitmentUtilization' = @{ Role = 'Cost Management Reader or Reservation Reader'; Scope = 'Reservation Order or Subscription'; API = 'Consumption Reservation Summaries API'; Reason = 'Requires Microsoft.Consumption/reservationSummaries/read. If no reservations exist, this will be empty.' }
+ 'Get-SavingsRealized' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Benefit Utilization API'; Reason = 'Requires Microsoft.CostManagement/benefitUtilizationSummaries/read. Returns empty if no active reservations or savings plans.' }
+ 'Get-BudgetStatus' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Budgets API'; Reason = 'Requires Microsoft.Consumption/budgets/read. Returns empty if no budgets are configured for scanned subscriptions.' }
+ 'Get-AnomalyAlerts' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Alerts API'; Reason = 'Requires Microsoft.CostManagement/alerts/read. Returns empty if no cost anomalies were detected.' }
+ 'Get-OptimizationAdvice' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Advisor API'; Reason = 'Requires Microsoft.Advisor/recommendations/read to retrieve cost optimization recommendations.' }
+ 'Get-BillingStructure' = @{ Role = 'Billing Reader or EA Reader'; Scope = 'Billing Account'; API = 'Billing API'; Reason = 'Requires Microsoft.Billing/*/read. This is a billing-scope role, not a subscription role. Contact your billing admin.' }
+ 'Get-ContractInfo' = @{ Role = 'Billing Reader'; Scope = 'Billing Account'; API = 'Billing API'; Reason = 'Requires Microsoft.Billing/billingProperty/read. May require billing account access beyond subscription Reader.' }
+ }
+
+ # =====================================================================
+ # BANNER
+ # =====================================================================
+ function Show-Banner {
+ Clear-Host
+ $banner = @"
+
+ ╔════════════════════════════════════════════════════════════════════════╗
+ ║ ║
+ ║ ███████╗██╗███╗ ██╗ ██████╗ ██████╗ ███████╗ ║
+ ║ ██╔════╝██║████╗ ██║██╔═══██╗██╔══██╗██╔════╝ ║
+ ║ █████╗ ██║██╔██╗ ██║██║ ██║██████╔╝███████╗ ║
+ ║ ██╔══╝ ██║██║╚██╗██║██║ ██║██╔═══╝ ╚════██║ ║
+ ║ ██║ ██║██║ ╚████║╚██████╔╝██║ ███████║ ║
+ ║ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚══════╝ ║
+ ║ ║
+ ║ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗████████╗ ██████╗ ██████╗ ██╗ ║
+ ║ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║╚══██╔══╝██╔═══██╗██╔═══██╗██║ ║
+ ║ ██╔████╔██║██║ ██║██║ ██║ ██║ ██║ ██║ ██║██║ ██║██║ ║
+ ║ ██║╚██╔╝██║██║ ██║██║ ██║ ██║ ██║ ██║ ██║██║ ██║██║ ║
+ ║ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗
+ ║ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
+ ║ ║
+ ║ Azure FinOps Scanner & Optimizer v2.3.0 ║
+ ║ ──────────────────────────────────────────────────────────── ║
+ ║ Part of the FinOps Toolkit ║
+ ║ ║
+ ╚════════════════════════════════════════════════════════════════════════╝
+
+"@
+ Write-Host $banner -ForegroundColor Cyan
+ }
+
+ # =====================================================================
+ # DATA SOURCE PICKER
+ # =====================================================================
+ function Select-DataSource {
+ param(
+ [string]$TenantId,
+ [array]$Subscriptions
+ )
+
+ Write-Host ""
+ Write-Host " DATA SOURCE" -ForegroundColor Cyan
+ Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray
+ Write-Host ""
+
+ # Try to detect a FinOps Hub in the selected subscriptions
+ $hubStorage = $null
+ Write-Host " Checking for FinOps Hub deployment..." -ForegroundColor DarkGray
+ foreach ($sub in $Subscriptions) {
+ try {
+ $query = "resources | where type == 'microsoft.storage/storageaccounts' and tags['cm-resource-parent'] contains 'Microsoft.Cloud/hubs' | project name, resourceGroup, subscriptionId, location"
+ $result = Search-AzGraph -Query $query -Subscription $sub.Id -ErrorAction SilentlyContinue
+ if ($result -and @($result).Count -gt 0) {
+ $hubStorage = $result[0]
+ break
+ }
+ }
+ catch { }
+ }
+
+ if ($hubStorage) {
+ Write-Host " FinOps Hub detected: " -ForegroundColor Green -NoNewline
+ Write-Host "$($hubStorage.name)" -ForegroundColor White -NoNewline
+ Write-Host " ($($hubStorage.resourceGroup))" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [1] FinOps Hub" -ForegroundColor Green -NoNewline
+ Write-Host " - Pre-processed data from your Hub's ingestion pipeline" -ForegroundColor DarkGray
+ Write-Host " Faster, consistent, includes normalized/amortized costs" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [2] Cost Management API" -ForegroundColor Yellow -NoNewline
+ Write-Host " - Query Azure Cost Management REST APIs directly" -ForegroundColor DarkGray
+ Write-Host " Real-time, no Hub required, subject to API throttling" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [3] Resource Graph only" -ForegroundColor DarkGray -NoNewline
+ Write-Host " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray
+ Write-Host ""
+
+ while ($true) {
+ Write-Host " Select [1/2/3]: " -ForegroundColor White -NoNewline
+ $choice = Read-Host
+ switch ($choice.Trim()) {
+ '1' { return @{ Source = 'Hub'; HubStorage = $hubStorage } }
+ '2' { return @{ Source = 'API'; HubStorage = $hubStorage } }
+ '3' { return @{ Source = 'GraphOnly'; HubStorage = $hubStorage } }
+ default { Write-Host " Invalid choice." -ForegroundColor Red }
+ }
+ }
+ }
+ else {
+ Write-Host " No FinOps Hub found in selected subscriptions." -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [1] Cost Management API" -ForegroundColor Yellow -NoNewline
+ Write-Host " - Query Azure Cost Management REST APIs directly" -ForegroundColor DarkGray
+ Write-Host " Real-time, subject to API throttling on large tenants" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " [2] Resource Graph only" -ForegroundColor DarkGray -NoNewline
+ Write-Host " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray
+ Write-Host ""
+
+ while ($true) {
+ Write-Host " Select [1/2]: " -ForegroundColor White -NoNewline
+ $choice = Read-Host
+ switch ($choice.Trim()) {
+ '1' { return @{ Source = 'API'; HubStorage = $null } }
+ '2' { return @{ Source = 'GraphOnly'; HubStorage = $null } }
+ default { Write-Host " Invalid choice." -ForegroundColor Red }
+ }
+ }
+ }
+ }
+
+ # =====================================================================
+ # SUBSCRIPTION PICKER
+ # =====================================================================
+ function Select-Subscription {
+ param([string]$PreselectedId)
+
+ Write-Host " Checking Azure connection..." -ForegroundColor DarkGray
+ $ctx = Get-AzContext -ErrorAction SilentlyContinue
+ if (-not $ctx) {
+ Write-Host " Not connected. Launching browser login..." -ForegroundColor Yellow
+ Connect-AzAccount | Out-Null
+ $ctx = Get-AzContext
+ }
+ Write-Host " Signed in as: $($ctx.Account.Id)" -ForegroundColor Green
+ Write-Host ""
+
+ # -- Tenant picker ------------------------------------------------
+ $tenants = @(Get-AzTenant -ErrorAction SilentlyContinue)
+ if ($tenants.Count -gt 1) {
+ Write-Host " $($tenants.Count) tenants available:" -ForegroundColor White
+ Write-Host ""
+
+ $tCursor = 0
+ $currentTenantId = $ctx.Tenant.Id
+ # Pre-select current tenant
+ for ($t = 0; $t -lt $tenants.Count; $t++) {
+ if ($tenants[$t].TenantId -eq $currentTenantId) { $tCursor = $t; break }
+ }
+
+ while ($true) {
+ [Console]::SetCursorPosition(0, [Console]::CursorTop)
+ for ($t = 0; $t -lt $tenants.Count; $t++) {
+ $tPrefix = if ($t -eq $tCursor) { ' > ' } else { ' ' }
+ $tColor = if ($t -eq $tCursor) { 'Green' } else { 'Gray' }
+ $tLabel = if ($tenants[$t].Name -and $tenants[$t].Name -ne $tenants[$t].TenantId) {
+ "$($tenants[$t].Name) ($($tenants[$t].TenantId))"
+ }
+ else { $tenants[$t].TenantId }
+ $current = if ($tenants[$t].TenantId -eq $currentTenantId) { ' (current)' } else { '' }
+ $tLine = "$tPrefix$tLabel$current"
+ if ($tLine.Length -gt 80) { $tLine = $tLine.Substring(0, 77) + '...' }
+ Write-Host $tLine.PadRight(85) -ForegroundColor $tColor
+ }
+ Write-Host ""
+ Write-Host " ↑↓ Navigate │ Enter = Select tenant │ Q = Stay in current" -ForegroundColor DarkGray
+
+ $tKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ switch ($tKey.VirtualKeyCode) {
+ 38 { if ($tCursor -gt 0) { $tCursor-- } }
+ 40 { if ($tCursor -lt $tenants.Count - 1) { $tCursor++ } }
+ 13 {
+ $selectedTenant = $tenants[$tCursor]
+ if ($selectedTenant.TenantId -ne $currentTenantId) {
+ Write-Host ""
+ Write-Host " Switching to tenant: $($selectedTenant.Name)..." -ForegroundColor Yellow
+ Connect-AzAccount -TenantId $selectedTenant.TenantId | Out-Null
+ $ctx = Get-AzContext
+ Write-Host " Connected to: $($ctx.Tenant.Id)" -ForegroundColor Green
+ }
+ else {
+ Write-Host ""
+ Write-Host " Staying in current tenant." -ForegroundColor Green
+ }
+ break
+ }
+ 81 {
+ Write-Host ""
+ Write-Host " Staying in current tenant." -ForegroundColor Green
+ break
+ }
+ }
+ if ($tKey.VirtualKeyCode -eq 13 -or $tKey.VirtualKeyCode -eq 81) { break }
+
+ # Move cursor back up to re-render
+ $tLinesToClear = $tenants.Count + 2
+ [Console]::SetCursorPosition(0, [Console]::CursorTop - $tLinesToClear)
+ }
+ Write-Host ""
+ }
+ elseif ($tenants.Count -eq 1) {
+ $tLabel = if ($tenants[0].Name -and $tenants[0].Name -ne $tenants[0].TenantId) { $tenants[0].Name } else { $tenants[0].TenantId }
+ Write-Host " Tenant: $tLabel" -ForegroundColor Green
+ Write-Host ""
+ }
+
+ if ($PreselectedId) {
+ $sub = Get-AzSubscription -SubscriptionId $PreselectedId -ErrorAction SilentlyContinue
+ if ($sub) {
+ Write-Host " Using subscription: $($sub.Name)" -ForegroundColor Green
+ return @($sub)
+ }
+ Write-Host " Subscription $PreselectedId not found, showing picker..." -ForegroundColor Yellow
+ }
+
+ $allSubs = @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' })
+ if ($allSubs.Count -eq 0) {
+ Write-Error "No enabled subscriptions found."
+ return $null
+ }
+ if ($allSubs.Count -eq 1) {
+ Write-Host " Using only subscription: $($allSubs[0].Name)" -ForegroundColor Green
+ return $allSubs
+ }
+
+ # Multi-sub picker
+ Write-Host " Found $($allSubs.Count) subscriptions. Select scope:" -ForegroundColor White
+ Write-Host ""
+ Write-Host " [A] All subscriptions" -ForegroundColor White
+ Write-Host " [S] Single subscription (pick from list)" -ForegroundColor White
+ Write-Host ""
+ $choice = Read-Host " Choice (A/S)"
+
+ if ($choice -eq 'A' -or $choice -eq 'a') {
+ Write-Host " Scanning all $($allSubs.Count) subscriptions" -ForegroundColor Green
+ return $allSubs
+ }
+
+ # Arrow-key single subscription picker
+ $cursor = 0
+ $pageSize = 15
+ $offset = 0
+
+ while ($true) {
+ # Render list
+ $renderStart = $offset
+ $renderEnd = [math]::Min($offset + $pageSize, $allSubs.Count) - 1
+ [Console]::SetCursorPosition(0, [Console]::CursorTop)
+
+ for ($i = $renderStart; $i -le $renderEnd; $i++) {
+ $prefix = if ($i -eq $cursor) { ' > ' } else { ' ' }
+ $color = if ($i -eq $cursor) { 'Green' } else { 'Gray' }
+ $line = "$prefix$($allSubs[$i].Name)"
+ if ($line.Length -gt 70) { $line = $line.Substring(0, 67) + '...' }
+ Write-Host $line.PadRight(75) -ForegroundColor $color
+ }
+ Write-Host ""
+ Write-Host " ↑↓ Navigate │ Enter = Select │ Q = Cancel" -ForegroundColor DarkGray
+
+ $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ switch ($key.VirtualKeyCode) {
+ 38 {
+ # Up
+ if ($cursor -gt 0) { $cursor-- }
+ if ($cursor -lt $offset) { $offset = $cursor }
+ }
+ 40 {
+ # Down
+ if ($cursor -lt $allSubs.Count - 1) { $cursor++ }
+ if ($cursor -ge $offset + $pageSize) { $offset = $cursor - $pageSize + 1 }
+ }
+ 13 {
+ # Enter
+ Write-Host ""
+ Write-Host " Selected: $($allSubs[$cursor].Name)" -ForegroundColor Green
+ return @($allSubs[$cursor])
+ }
+ 81 { return $null } # Q
+ }
+
+ # Move cursor back up to re-render
+ $linesToClear = ($renderEnd - $renderStart + 1) + 2
+ [Console]::SetCursorPosition(0, [Console]::CursorTop - $linesToClear)
+ }
+ }
+
+ # =====================================================================
+ # SCAN MODULE PICKER (checkbox menu)
+ # =====================================================================
+ function Select-ScanModules {
+ param([array]$Modules)
+
+ $cursor = 0
+ $categories = $Modules | ForEach-Object { $_.Category } | Select-Object -Unique
+
+ while ($true) {
+ # Build display lines grouped by category
+ $lines = @()
+ $lineToIndex = @{} # map display line -> module index
+ $moduleIdx = 0
+
+ foreach ($cat in $categories) {
+ $lines += " ── $cat ──"
+ $lineToIndex[$lines.Count - 1] = -1 # category header, not selectable
+
+ $catModules = $Modules | Where-Object { $_.Category -eq $cat }
+ foreach ($mod in $catModules) {
+ $idx = [array]::IndexOf($Modules, $mod)
+ $check = if ($mod.Selected) { '[x]' } else { '[ ]' }
+ $lines += " $check $($mod.Name)"
+ $lineToIndex[$lines.Count - 1] = $idx
+ }
+ $lines += ''
+ $lineToIndex[$lines.Count - 1] = -1
+ }
+
+ # Find selectable line indices
+ $selectableLines = @()
+ for ($i = 0; $i -lt $lines.Count; $i++) {
+ if ($lineToIndex[$i] -ge 0) { $selectableLines += $i }
+ }
+
+ if ($cursor -ge $selectableLines.Count) { $cursor = $selectableLines.Count - 1 }
+ $activeLine = $selectableLines[$cursor]
+
+ # Render
+ Clear-Host
+ Write-Host ""
+ Write-Host " SELECT SCANS" -ForegroundColor White
+ Write-Host " ↑↓ Move │ Space = Toggle │ A = All │ N = None │ Enter = Run │ Q = Quit" -ForegroundColor DarkGray
+ Write-Host ""
+
+ $selectedCount = ($Modules | Where-Object { $_.Selected }).Count
+
+ for ($i = 0; $i -lt $lines.Count; $i++) {
+ if ($lineToIndex[$i] -eq -1) {
+ # Category header or blank
+ if ($lines[$i] -match '──') {
+ Write-Host $lines[$i] -ForegroundColor Yellow
+ }
+ else {
+ Write-Host $lines[$i]
+ }
+ }
+ else {
+ $isActive = ($i -eq $activeLine)
+ $mod = $Modules[$lineToIndex[$i]]
+ $check = if ($mod.Selected) { '[x]' } else { '[ ]' }
+ $pointer = if ($isActive) { ' >' } else { ' ' }
+ $color = if ($isActive -and $mod.Selected) { 'Green' }
+ elseif ($isActive) { 'White' }
+ elseif ($mod.Selected) { 'DarkGreen' }
+ else { 'Gray' }
+ Write-Host " $pointer $check $($mod.Name)" -ForegroundColor $color
+ }
+ }
+
+ Write-Host ""
+ Write-Host " $selectedCount of $($Modules.Count) scans selected" -ForegroundColor DarkGray
+ Write-Host ""
+
+ # Read key
+ $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ switch ($key.VirtualKeyCode) {
+ 38 { if ($cursor -gt 0) { $cursor-- } } # Up
+ 40 { if ($cursor -lt $selectableLines.Count - 1) { $cursor++ } } # Down
+ 32 {
+ # Space = toggle
+ $modIdx = $lineToIndex[$selectableLines[$cursor]]
+ $Modules[$modIdx].Selected = -not $Modules[$modIdx].Selected
+ }
+ 65 {
+ # A = select all
+ foreach ($m in $Modules) { $m.Selected = $true }
+ }
+ 78 {
+ # N = select none
+ foreach ($m in $Modules) { $m.Selected = $false }
+ }
+ 13 {
+ # Enter = run
+ $selected = $Modules | Where-Object { $_.Selected }
+ if ($selected.Count -eq 0) {
+ Write-Host " No scans selected. Press any key..." -ForegroundColor Red
+ $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ }
+ else { return $Modules }
+ }
+ 81 { return $null } # Q = quit
+ }
+ }
+ }
+
+ # =====================================================================
+ # RUN SELECTED SCANS
+ # =====================================================================
+ function Invoke-SelectedScans {
+ param(
+ [array]$Modules,
+ [array]$Subscriptions,
+ [string]$TenantId,
+ [hashtable]$DataSource
+ )
+
+ $selected = $Modules | Where-Object { $_.Selected }
+ $results = @{}
+ $total = $selected.Count
+ $current = 0
+
+ # Pre-load Hub data if Hub source selected
+ $hubCostData = $null
+ $hubResourceCosts = $null
+ $hubRaw = $null
+ $hubTagInventory = $null
+ if ($DataSource.HubStorage) {
+ # Always load Hub data when available — used for instant tag/cost-by-tag
+ $hub = $DataSource.HubStorage
+ if ($DataSource.Source -eq 'Hub') {
+ Write-Host ""
+ Write-Host " Loading cost data from FinOps Hub..." -ForegroundColor Green
+ }
+ else {
+ Write-Host " Loading Hub tag data for fast tag scans..." -ForegroundColor DarkGray
+ }
+ try {
+ $hubRaw = Read-FinOpsHubData -StorageAccountName $hub.name -ResourceGroupName $hub.resourceGroup -Months 1
+ }
+ catch {
+ Write-Host " Hub data load failed: $($_.Exception.Message)" -ForegroundColor Yellow
+ $hubRaw = $null
+ }
+ if ($hubRaw -and @($hubRaw).Count -gt 0) {
+ $hubTagInventory = ConvertTo-TagInventoryFromHub -HubData $hubRaw
+
+ # Hub tag coverage only reflects resources with cost data — query ARG for true counts
+ try {
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $totalBody = @{
+ subscriptions = @($subIds)
+ query = "resources | summarize TotalCount = count()"
+ } | ConvertTo-Json -Depth 5
+ $totalResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $totalBody
+ if ($totalResp.StatusCode -eq 200) {
+ $totalData = ($totalResp.Content | ConvertFrom-Json)
+ if ($totalData.data -and $totalData.data.Count -gt 0) {
+ $argTotal = [int]$totalData.data[0].TotalCount
+
+ $untaggedBody = @{
+ subscriptions = @($subIds)
+ query = "resources | where isnull(tags) or tags == '{}' | summarize UntaggedCount = count()"
+ } | ConvertTo-Json -Depth 5
+ $untaggedResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $untaggedBody
+ if ($untaggedResp.StatusCode -eq 200) {
+ $untaggedData = ($untaggedResp.Content | ConvertFrom-Json)
+ $argUntagged = if ($untaggedData.data -and $untaggedData.data.Count -gt 0) { [int]$untaggedData.data[0].UntaggedCount } else { 0 }
+
+ $argTagged = $argTotal - $argUntagged
+ $argCoverage = if ($argTotal -gt 0) { [math]::Round(($argTagged / $argTotal) * 100, 1) } else { 0 }
+
+ # Override Hub coverage with ARG-based coverage
+ $hubTagInventory = $hubTagInventory | ForEach-Object {
+ $_.TotalResources = $argTotal
+ $_.TaggedCount = $argTagged
+ $_.UntaggedCount = $argUntagged
+ $_.TagCoverage = $argCoverage
+ $_
+ }
+ Write-Host " Tag coverage corrected via Resource Graph: $argCoverage% ($argTagged/$argTotal)" -ForegroundColor DarkGray
+ }
+ }
+ }
+ }
+ catch {
+ Write-Host " Could not verify tag coverage via ARG: $($_.Exception.Message)" -ForegroundColor DarkGray
+ }
+
+ if ($DataSource.Source -eq 'Hub') {
+ $hubCostData = ConvertTo-CostDataFromHub -HubData $hubRaw
+ $hubResourceCosts = ConvertTo-ResourceCostsFromHub -HubData $hubRaw
+
+ # Hub exports are historical actuals — enrich with live forecast from Cost Management API
+ try {
+ Write-Host " Fetching forecast data from Cost Management API..." -ForegroundColor DarkGray
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+ $forecastFilled = $false
+
+ # Try MG-scope forecast first
+ $fctTenantId = (Get-AzContext).Tenant.Id
+ if ($fctTenantId) {
+ $fctBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @(@{ type = 'Dimension'; name = 'SubscriptionId' })
+ }
+ includeActualCost = $false
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $fctPath = "/providers/Microsoft.Management/managementGroups/$fctTenantId/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01"
+ $fctResp = Invoke-AzRestMethodWithRetry -Path $fctPath -Method POST -Payload $fctBody
+ if ($fctResp.StatusCode -eq 200) {
+ $fctResult = ($fctResp.Content | ConvertFrom-Json)
+ if ($fctResult.properties.rows -and $fctResult.properties.rows.Count -gt 0) {
+ $fctSums = @{}
+ foreach ($row in $fctResult.properties.rows) {
+ $subId = $row[1]
+ if (-not $fctSums.ContainsKey($subId)) { $fctSums[$subId] = 0 }
+ $fctSums[$subId] += [double]$row[0]
+ }
+ foreach ($subId in $fctSums.Keys) {
+ if ($hubCostData.ContainsKey($subId)) {
+ # Full-month projection = actual MTD + remaining forecast
+ $actual = $hubCostData[$subId].Actual
+ $hubCostData[$subId].Forecast = [math]::Round($actual + $fctSums[$subId], 2)
+ }
+ }
+ $forecastFilled = $true
+ Write-Host " Forecast data loaded for $($fctSums.Count) subscription(s)" -ForegroundColor Green
+ }
+ }
+ }
+
+ # Per-subscription fallback if MG-scope failed
+ if (-not $forecastFilled -and $Subscriptions) {
+ $fctHits = 0
+ foreach ($sub in $Subscriptions) {
+ try {
+ $fBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ }
+ includeActualCost = $false
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+ $fResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" -Method POST -Payload $fBody
+ if ($fResp.StatusCode -eq 200) {
+ $fRes = ($fResp.Content | ConvertFrom-Json)
+ if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) {
+ $fctTotal = 0
+ foreach ($row in $fRes.properties.rows) { $fctTotal += [double]$row[0] }
+ if ($hubCostData.ContainsKey($sub.Id)) {
+ # Full-month projection = actual MTD + remaining forecast
+ $actual = $hubCostData[$sub.Id].Actual
+ $hubCostData[$sub.Id].Forecast = [math]::Round($actual + $fctTotal, 2)
+ $fctHits++
+ }
+ }
+ }
+ }
+ catch { }
+ }
+ if ($fctHits -gt 0) { Write-Host " Forecast data loaded for $fctHits subscription(s)" -ForegroundColor Green }
+ }
+ }
+ catch {
+ Write-Host " Forecast data unavailable: $($_.Exception.Message)" -ForegroundColor DarkGray
+ }
+
+ Write-Host " Hub data loaded: $(@($hubRaw).Count) cost records, $($hubTagInventory.TagCount) tags, $($hubTagInventory.TagCoverage)% coverage" -ForegroundColor Green
+ }
+ else {
+ Write-Host " Hub tag data ready: $($hubTagInventory.TagCount) tags, $($hubTagInventory.TagCoverage)% coverage" -ForegroundColor DarkGray
+ }
+ }
+ else {
+ $hubRaw = $null
+ if ($DataSource.Source -eq 'Hub') {
+ Write-Host " No Hub data found — falling back to Cost Management API" -ForegroundColor Yellow
+ $DataSource.Source = 'API'
+ }
+ }
+ if ($DataSource.Source -eq 'Hub') { Write-Host "" }
+ }
+
+ $srcLabel = switch ($DataSource.Source) {
+ 'Hub' { "FinOps Hub ($($DataSource.HubStorage.name))" }
+ 'API' { "Cost Management API (real-time)" }
+ 'GraphOnly' { "Resource Graph only" }
+ }
+ Write-SectionHeader "RUNNING $total SCANS"
+ $srcColor = switch ($DataSource.Source) { 'Hub' { 'Green' } 'API' { 'Yellow' } 'GraphOnly' { 'DarkGray' } }
+ Write-Host " $srcLabel" -ForegroundColor $srcColor
+ Write-Host ""
+
+ foreach ($mod in $selected) {
+ $current++
+ $pct = [math]::Round(($current / $total) * 100)
+ $bar = ('█' * [math]::Floor($pct / 5)).PadRight(20, '░')
+
+ Write-Host " [$bar] $pct% ($current/$total) $($mod.Name)" -ForegroundColor White -NoNewline
+
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
+ try {
+ $fn = $mod.Fn
+ $output = $null
+
+ # Route parameters based on what each function expects
+ # Hub shortcut: return pre-loaded Hub data for cost/tag modules
+ switch ($fn) {
+ { $_ -eq 'Get-CostData' -and $hubCostData } {
+ $output = $hubCostData; break
+ }
+ { $_ -eq 'Get-ResourceCosts' -and $hubResourceCosts } {
+ $output = $hubResourceCosts; break
+ }
+ { $_ -eq 'Get-TagInventory' -and $hubTagInventory } {
+ $output = $hubTagInventory; break
+ }
+ { $_ -eq 'Get-CostByTag' -and $hubRaw } {
+ # Build cost-by-tag from Hub data — zero API calls
+ $existingTags = if ($results.ContainsKey('Get-TagInventory') -and $results['Get-TagInventory'].TagNames) {
+ $results['Get-TagInventory'].TagNames
+ }
+ elseif ($hubTagInventory) { $hubTagInventory.TagNames }
+ else { @{} }
+ $output = ConvertTo-CostByTagFromHub -HubData $hubRaw -ExistingTags $existingTags; break
+ }
+ 'Get-TagRecommendations' {
+ $tags = if ($results.ContainsKey('Get-TagInventory') -and $results['Get-TagInventory'].TagNames) {
+ $results['Get-TagInventory'].TagNames
+ }
+ elseif ($hubTagInventory) { $hubTagInventory.TagNames }
+ else { @{} }
+ $output = & $fn -ExistingTags $tags; break
+ }
+ 'Get-PolicyRecommendations' {
+ $assignments = if ($results.ContainsKey('Get-PolicyInventory') -and $results['Get-PolicyInventory'].Assignments) {
+ $results['Get-PolicyInventory'].Assignments
+ }
+ else { @() }
+ $output = & $fn -ExistingAssignments $assignments; break
+ }
+ 'Get-BudgetStatus' {
+ $costData = if ($results.ContainsKey('Get-CostData') -and $results['Get-CostData'] -is [hashtable]) {
+ $results['Get-CostData']
+ }
+ elseif ($hubCostData -is [hashtable]) { $hubCostData }
+ else { @{} }
+ $output = & $fn -Subscriptions $Subscriptions -CostData $costData; break
+ }
+ { $_ -eq 'Get-CostByTag' -and -not $hubRaw } {
+ # No Hub data — fall back to API
+ $existingTags = if ($results.ContainsKey('Get-TagInventory') -and $results['Get-TagInventory'].TagNames) {
+ $results['Get-TagInventory'].TagNames
+ }
+ else { @{} }
+ $output = & $fn -TenantId $TenantId -ExistingTags $existingTags -Subscriptions $Subscriptions; break
+ }
+ default {
+ # Build params — include TenantId if the function accepts it
+ $params = @{ Subscriptions = $Subscriptions }
+ $cmdInfo = Get-Command $fn -ErrorAction SilentlyContinue
+ if ($cmdInfo -and $cmdInfo.Parameters.ContainsKey('TenantId') -and $TenantId) {
+ $params['TenantId'] = $TenantId
+ }
+ $output = & $fn @params
+ }
+ }
+
+ $sw.Stop()
+ $count = if ($output) { @($output).Count } else { 0 }
+ $results[$fn] = $output
+
+ Write-Host "`r [$bar] $pct% ($current/$total) $($mod.Name) " -ForegroundColor Green -NoNewline
+ Write-Host " $count results ($([math]::Round($sw.Elapsed.TotalSeconds, 1))s)" -ForegroundColor DarkGray
+ }
+ catch {
+ $sw.Stop()
+ Write-Host "`r [$bar] $pct% ($current/$total) $($mod.Name) " -ForegroundColor Red -NoNewline
+ Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red
+ $results[$mod.Fn] = @()
+ $results["_error_$($mod.Fn)"] = $_.Exception.Message
+ }
+ }
+
+ return $results
+ }
+
+ # =====================================================================
+ # RESULTS SUMMARY
+ # =====================================================================
+ function Write-SectionHeader {
+ param([string]$Title, [string]$Color = 'Cyan')
+ $line = '═' * 55
+ Write-Host ""
+ Write-Host " $line" -ForegroundColor $Color
+ Write-Host " $Title" -ForegroundColor $Color
+ Write-Host " $line" -ForegroundColor $Color
+ }
+
+ # Write a line with dollar amounts ($1,234) highlighted in green
+ function Write-ColorizedLine {
+ param(
+ [string]$Text,
+ [string]$DefaultColor = 'White',
+ [string]$MoneyColor = 'Green'
+ )
+ # Split on dollar-amount patterns, render them in green
+ $parts = [regex]::Split($Text, '(\$[\d,]+\.?\d*(?:/\w+)?)')
+ foreach ($part in $parts) {
+ if ($part -match '^\$[\d,]+\.?\d*') {
+ Write-Host $part -ForegroundColor $MoneyColor -NoNewline
+ }
+ else {
+ Write-Host $part -ForegroundColor $DefaultColor -NoNewline
+ }
+ }
+ Write-Host ""
+ }
+ function Show-ResultsSummary {
+ param(
+ [hashtable]$Results,
+ [array]$Modules,
+ [string]$ExportPath,
+ [array]$Subscriptions
+ )
+
+ # Build sub ID → name lookup for display functions
+ $subNameLookup = @{}
+ if ($Subscriptions) { foreach ($s in $Subscriptions) { if ($s.Id -and $s.Name) { $subNameLookup[$s.Id] = $s.Name } } }
+
+ Write-SectionHeader 'SCAN COMPLETE'
+ Write-Host ""
+
+ $totalFindings = 0
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $data = $Results[$mod.Fn]
+ $errorKey = "_error_$($mod.Fn)"
+ $hasError = $Results.ContainsKey($errorKey)
+ $count = if ($data) { @($data).Count } else { 0 }
+ $totalFindings += $count
+ if ($hasError) {
+ $icon = '!'
+ $color = 'Red'
+ $suffix = 'error (see details below)'
+ }
+ elseif ($count -gt 0) {
+ $icon = '*'
+ $color = 'Yellow'
+ $suffix = "$count findings"
+ }
+ else {
+ $icon = '-'
+ $color = 'DarkGray'
+ $suffix = '0 findings'
+ }
+ Write-Host " $icon $($mod.Name.PadRight(30)) $suffix" -ForegroundColor $color
+ }
+
+ Write-Host ""
+ Write-Host " Total findings: $totalFindings" -ForegroundColor White
+ Write-Host ""
+
+ # -- Display results per module ------------------------------------
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $data = $Results[$mod.Fn]
+ if (-not $data -or @($data).Count -eq 0) {
+ # Show why data is missing — error or permissions
+ $errorKey = "_error_$($mod.Fn)"
+ $errorMsg = if ($Results.ContainsKey($errorKey)) { $Results[$errorKey] } else { $null }
+ $pInfo = if ($permissionInfo.ContainsKey($mod.Fn)) { $permissionInfo[$mod.Fn] } else { $null }
+
+ Write-SectionHeader $mod.Name
+ if ($errorMsg) {
+ # Detect permission-related errors
+ $isPermError = $errorMsg -match '(?i)403|401|Forbidden|Unauthorized|AuthorizationFailed|does not have authorization|InsufficientPermissions|BillingAccountNotFound'
+ if ($isPermError -and $pInfo) {
+ Write-Host " [!] ACCESS DENIED" -ForegroundColor Red
+ Write-Host " $errorMsg" -ForegroundColor DarkGray
+ Write-Host ""
+ Write-Host " Required role: $($pInfo.Role)" -ForegroundColor Yellow
+ Write-Host " Scope: $($pInfo.Scope)" -ForegroundColor Yellow
+ Write-Host " API: $($pInfo.API)" -ForegroundColor DarkGray
+ Write-Host " $($pInfo.Reason)" -ForegroundColor DarkGray
+ }
+ else {
+ Write-Host " [!] ERROR: $errorMsg" -ForegroundColor Red
+ if ($pInfo) {
+ Write-Host " If this is a permissions issue:" -ForegroundColor DarkGray
+ Write-Host " Required role: $($pInfo.Role) at $($pInfo.Scope) scope" -ForegroundColor DarkGray
+ }
+ }
+ }
+ else {
+ # No error but no data — could be legitimately empty
+ Write-Host " No data returned." -ForegroundColor DarkGray
+ if ($pInfo) {
+ Write-Host " Possible reasons:" -ForegroundColor DarkGray
+ Write-Host " - $($pInfo.Reason)" -ForegroundColor DarkGray
+ Write-Host " - Required role: $($pInfo.Role) at $($pInfo.Scope) scope" -ForegroundColor DarkGray
+ }
+ }
+ Write-Host ""
+ continue
+ }
+
+ Write-SectionHeader $mod.Name
+
+ # Extract the displayable rows and columns per module
+ $rows = $null
+ $cols = $null
+
+ switch ($mod.Fn) {
+ 'Get-OrphanedResources' {
+ $rows = $data.Orphans
+ $cols = @('Category', 'ResourceName', 'ResourceGroup', 'Detail')
+ }
+ 'Get-IdleVMs' {
+ $scanned = if ($data.ScannedVMs) { $data.ScannedVMs } else { 0 }
+ if ($data.IdleVMs -and @($data.IdleVMs).Count -gt 0) {
+ Write-Host " Scanned $scanned running VMs — $(@($data.IdleVMs).Count) idle/underutilized" -ForegroundColor White
+ $rows = $data.IdleVMs
+ $cols = @('VMName', 'ResourceGroup', 'VMSize', 'AvgCPU14d', 'Classification')
+ }
+ else {
+ Write-Host " Scanned $scanned running VMs — no idle or underutilized VMs detected" -ForegroundColor Green
+ }
+ }
+ 'Get-StorageTierAdvice' {
+ $hotCount = if ($data.TotalHotAccounts) { $data.TotalHotAccounts } else { 0 }
+ if ($data.Recommendations -and @($data.Recommendations).Count -gt 0) {
+ Write-Host " $hotCount Hot-tier accounts scanned — $(@($data.Recommendations).Count) can be optimized" -ForegroundColor White
+ $rows = $data.Recommendations
+ $cols = @('StorageAccount', 'ResourceGroup', 'CurrentTier', 'CapacityGB', 'Recommendation')
+ }
+ else {
+ Write-Host " $hotCount Hot-tier accounts scanned — all are appropriately tiered" -ForegroundColor Green
+ }
+ }
+ 'Get-AHBOpportunities' {
+ $rows = @()
+ if ($data.WindowsVMs) {
+ $rows += @($data.WindowsVMs) | ForEach-Object {
+ [PSCustomObject]@{ Type = 'Windows VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.vmSize; License = $_.currentLicense }
+ }
+ }
+ if ($data.SQLVMs) {
+ $rows += @($data.SQLVMs) | ForEach-Object {
+ [PSCustomObject]@{ Type = 'SQL VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sqlEdition; License = $_.currentLicense }
+ }
+ }
+ if ($data.SQLDatabases) {
+ $rows += @($data.SQLDatabases) | ForEach-Object {
+ [PSCustomObject]@{ Type = 'SQL DB'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sku; License = $_.currentLicense }
+ }
+ }
+ $cols = @('Type', 'Name', 'ResourceGroup', 'Size', 'License')
+ }
+ 'Get-ReservationAdvice' {
+ $rows = $data.AdvisorRecommendations | ForEach-Object {
+ $resLabel = if ($_.Subscription -and $_.Subscription -ne $_.SubscriptionId) { $_.Subscription }
+ elseif ($_.Solution) { $_.Solution.Substring(0, [math]::Min(50, $_.Solution.Length)) }
+ else { ($_.ResourceName -split '/')[-1] }
+ [PSCustomObject]@{
+ Resource = $resLabel
+ Type = ($_.ResourceType -split '/')[-1]
+ Term = $_.Term
+ Savings = '{0:C0}' -f [double]$_.AnnualSavings
+ Impact = $_.Impact
+ }
+ }
+ $cols = @('Resource', 'Type', 'Term', 'Savings', 'Impact')
+ if ($data.EstimatedAnnualSavings) {
+ Write-ColorizedLine -Text " Est. annual savings: $($data.EstimatedAnnualSavings.ToString('C0'))" -DefaultColor 'White'
+ }
+ }
+ 'Get-CommitmentUtilization' {
+ if ($data.HasData) {
+ Write-Host " RIs: $($data.RICount) (avg $($data.RIAvgUtilization)% util) | Savings Plans: $($data.SPCount) (avg $($data.SPAvgUtilization)% util)" -ForegroundColor White
+ $rows = $data.UnderutilizedRIs | ForEach-Object {
+ [PSCustomObject]@{ SKU = $_.SkuName; Kind = $_.Kind; AvgUtil = "$($_.AvgUtilization)%"; MinUtil = "$($_.MinUtilization)%" }
+ }
+ $cols = @('SKU', 'Kind', 'AvgUtil', 'MinUtil')
+ }
+ else {
+ Write-Host " No active reservations or savings plans found." -ForegroundColor DarkGray
+ }
+ }
+ 'Get-SavingsRealized' {
+ Write-Host " Monthly savings breakdown:" -ForegroundColor White
+ Write-ColorizedLine -Text " RI: $($data.RISavingsMonthly.ToString('C0')) SP: $($data.SPSavingsMonthly.ToString('C0')) AHB: $($data.AHBSavingsMonthly.ToString('C0'))" -DefaultColor 'Cyan'
+ Write-ColorizedLine -Text " Total monthly: $($data.TotalMonthly.ToString('C0')) Annual: $($data.TotalAnnual.ToString('C0'))" -DefaultColor 'White'
+ $rows = $null # summary only
+ }
+ 'Get-CostData' {
+ # CostData is a hashtable keyed by subscription ID
+ if ($data -is [hashtable]) {
+ $rows = $data.GetEnumerator() | ForEach-Object {
+ $subLabel = if ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } else { $_.Key.Substring(0, [Math]::Min(36, $_.Key.Length)) }
+ [PSCustomObject]@{
+ Subscription = $subLabel
+ Actual = '{0:C0}' -f [double]$_.Value.Actual
+ Forecast = '{0:C0}' -f [double]$_.Value.Forecast
+ Currency = $_.Value.Currency
+ }
+ }
+ $cols = @('Subscription', 'Actual', 'Forecast', 'Currency')
+ }
+ }
+ 'Get-ResourceCosts' {
+ $rows = @($data) | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 20 | ForEach-Object {
+ [PSCustomObject]@{
+ ResourceGroup = $_.ResourceGroup
+ ResourceType = ($_.ResourceType -split '/')[-1]
+ Cost = '{0:C2}' -f [double]$_.Actual
+ }
+ }
+ $cols = @('ResourceGroup', 'ResourceType', 'Cost')
+ if (@($data).Count -gt 20) {
+ Write-Host " (showing top 20 of $(@($data).Count) resources by cost)" -ForegroundColor DarkGray
+ }
+ }
+ 'Get-CostByTag' {
+ if ($data.CostByTag -and $data.CostByTag.Count -gt 0) {
+ if ($data.Source) { Write-Host " Source: $($data.Source)" -ForegroundColor DarkGray }
+ $rows = foreach ($tag in $data.CostByTag.GetEnumerator()) {
+ foreach ($v in $tag.Value) {
+ $displayVal = if ($v.TagValue.Length -gt 40) { $v.TagValue.Substring(0, 37) + '...' } else { $v.TagValue }
+ [PSCustomObject]@{ Tag = $tag.Key; Value = $displayVal; Cost = '{0:C0}' -f [double]$v.Cost }
+ }
+ }
+ $cols = @('Tag', 'Value', 'Cost')
+ }
+ elseif ($data.NoTagsFound) {
+ Write-Host " No tags found in environment to query cost against." -ForegroundColor DarkGray
+ }
+ else {
+ $tagCount = if ($data.TagsQueried) { $data.TagsQueried.Count } else { 0 }
+ $cbtCount = if ($data.CostByTag) { $data.CostByTag.Count } else { 0 }
+ Write-Host " Tags queried: $tagCount, results: $cbtCount — no cost data returned." -ForegroundColor DarkGray
+ if ($data.UsedTimeframe) { Write-Host " Timeframe: $($data.UsedTimeframe)" -ForegroundColor DarkGray }
+ }
+ }
+ 'Get-CostTrend' {
+ # Show per-subscription trend when BySubscription data is available
+ if ($data.BySubscription -and $data.BySubscription.Count -gt 0) {
+ foreach ($subEntry in $data.BySubscription.GetEnumerator()) {
+ $subName = if ($subNameLookup.ContainsKey($subEntry.Key)) { $subNameLookup[$subEntry.Key] } else { $subEntry.Key }
+ Write-Host " $subName" -ForegroundColor White
+ $subRows = $subEntry.Value | ForEach-Object {
+ [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency }
+ }
+ @($subRows) | Format-Table -AutoSize | Out-String | ForEach-Object {
+ $lines = $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() }
+ $hdrDone = $false
+ foreach ($ln in $lines) {
+ if (-not $hdrDone) {
+ if ($ln -match '^[\s\-]+$') { Write-Host " $ln" -ForegroundColor DarkCyan; $hdrDone = $true }
+ else { Write-Host " $ln" -ForegroundColor Cyan }
+ }
+ else { Write-ColorizedLine -Text " $ln" -DefaultColor 'White' }
+ }
+ }
+ }
+ # Skip default table rendering
+ $rows = $null
+ $cols = $null
+ }
+ else {
+ # Fallback: show aggregate with sub name header
+ if ($Subscriptions -and $Subscriptions.Count -gt 0) {
+ $subNames = ($Subscriptions | ForEach-Object { if ($_.Name) { $_.Name } else { $_.Id } }) -join ', '
+ Write-Host " $subNames" -ForegroundColor White
+ }
+ $rows = $data.Months | ForEach-Object {
+ [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency }
+ }
+ $cols = @('Month', 'Cost', 'Currency')
+ }
+ }
+ 'Get-TagInventory' {
+ Write-Host " Coverage: $($data.TagCoverage)% | $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged | $($data.TagCount) unique tags" -ForegroundColor White
+ if ($data.TagNames -and $data.TagNames.Count -gt 0) {
+ $rows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object {
+ [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; UniqueValues = @($_.Value.Values).Count }
+ }
+ $cols = @('Tag', 'Resources', 'UniqueValues')
+ }
+ }
+ 'Get-TagRecommendations' {
+ $rows = $data.Analysis | ForEach-Object {
+ [PSCustomObject]@{ Tag = $_.TagName; Status = $_.Status; Priority = $_.Priority; Pillar = $_.Pillar; Example = $_.Example }
+ }
+ $cols = @('Tag', 'Status', 'Priority', 'Pillar', 'Example')
+ Write-Host " Compliance: $($data.CompliancePercent)%" -ForegroundColor White
+ }
+ 'Get-PolicyInventory' {
+ Write-Host " Assignments: $($data.AssignmentCount) | Compliance: $($data.CompliancePct)% ($($data.TotalCompliant) compliant, $($data.TotalNonCompliant) non-compliant)" -ForegroundColor White
+ $rows = $data.Assignments | Select-Object -First 15 | ForEach-Object {
+ # Parse scope into a readable label
+ $scopeRaw = $_.Scope
+ $scopeLabel = if ($scopeRaw -match '/managementGroups/([^/]+)') {
+ $mgId = $Matches[1]
+ if ($mgId.Length -gt 20) { "MG: $($mgId.Substring(0,17))..." } else { "MG: $mgId" }
+ }
+ elseif ($scopeRaw -match '/resourceGroups/([^/]+)') { "RG: $($Matches[1])" }
+ elseif ($scopeRaw -match '/subscriptions/([^/]+)') {
+ $subId = $Matches[1]
+ $subName = if ($subNameLookup.ContainsKey($subId)) { $subNameLookup[$subId] } else { $subId.Substring(0, 8) + '...' }
+ "Sub: $subName"
+ }
+ else { $scopeRaw }
+ # Truncate long policy names (some embed subscription GUIDs)
+ $displayName = $_.AssignmentName
+ if ($displayName.Length -gt 60) { $displayName = $displayName.Substring(0, 57) + '...' }
+ [PSCustomObject]@{ Name = $displayName; Effect = $_.Effect; Enforcement = $_.EnforcementMode; Scope = $scopeLabel }
+ }
+ $cols = @('Name', 'Effect', 'Enforcement', 'Scope')
+ if ($data.AssignmentCount -gt 15) {
+ Write-Host " (showing 15 of $($data.AssignmentCount) assignments)" -ForegroundColor DarkGray
+ }
+ }
+ 'Get-PolicyRecommendations' {
+ $rows = $data.Analysis | ForEach-Object {
+ [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect }
+ }
+ $cols = @('Policy', 'Status', 'Category', 'Priority', 'Effect')
+ Write-Host " Compliance: $($data.CompliancePct)%" -ForegroundColor White
+ }
+ 'Get-BudgetStatus' {
+ $bSumColor = if ($data.OverBudgetCount -gt 0) { 'Red' } elseif ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' }
+ Write-Host " Budgets: $($data.TotalBudgets) | " -ForegroundColor White -NoNewline
+ Write-Host "At risk: $($data.AtRiskCount)" -ForegroundColor $(if ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' }) -NoNewline
+ Write-Host " | " -ForegroundColor White -NoNewline
+ Write-Host "Over budget: $($data.OverBudgetCount)" -ForegroundColor $(if ($data.OverBudgetCount -gt 0) { 'Red' } else { 'Green' }) -NoNewline
+ Write-Host " | Coverage: $($data.BudgetCoverage)%" -ForegroundColor White
+ $rows = $data.Budgets | ForEach-Object {
+ [PSCustomObject]@{
+ Budget = $_.BudgetName
+ Amount = '{0:C0}' -f [double]$_.Amount
+ Spent = '{0:C0}' -f [double]$_.ActualSpend
+ PctUsed = "$($_.PctUsed)%"
+ Risk = $_.Risk
+ }
+ }
+ $cols = @('Budget', 'Amount', 'Spent', 'PctUsed', 'Risk')
+ }
+ 'Get-AnomalyAlerts' {
+ Write-Host " Alerts: $($data.TotalAlerts) | Anomaly: $($data.AnomalyAlertCount) | Active: $($data.ActiveAlertCount) | Rules: $($data.ConfiguredRuleCount)" -ForegroundColor White
+ $rows = $data.TriggeredAlerts | Select-Object -First 10 | ForEach-Object {
+ [PSCustomObject]@{ Alert = $_.AlertName; Type = $_.AlertType; Status = $_.Status; Subscription = $_.Subscription }
+ }
+ $cols = @('Alert', 'Type', 'Status', 'Subscription')
+ }
+ 'Get-BillingStructure' {
+ $rows = $data.BillingAccounts | ForEach-Object {
+ [PSCustomObject]@{ Account = $_.DisplayName; Agreement = $_.AgreementType; Type = $_.AccountType; Status = $_.AccountStatus }
+ }
+ $cols = @('Account', 'Agreement', 'Type', 'Status')
+ }
+ 'Get-ContractInfo' {
+ $rows = @($data) | ForEach-Object {
+ [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Currency = $_.Currency; Status = $_.AccountStatus }
+ }
+ $cols = @('Account', 'Agreement', 'Type', 'Currency', 'Status')
+ }
+ 'Get-OptimizationAdvice' {
+ if ($data.EstimatedAnnualSavings) {
+ Write-ColorizedLine -Text " Est. annual savings: `$$($data.EstimatedAnnualSavings) | $($data.TotalCount) recommendations" -DefaultColor 'White'
+ }
+ $rows = $data.Recommendations | Sort-Object { if ($_.AnnualSavings) { [double]$_.AnnualSavings } else { 0 } } -Descending | Select-Object -First 15 | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Category
+ Impact = $_.Impact
+ Resource = $_.ResourceName
+ Problem = ($_.Problem -replace '(.{60}).+', '$1...')
+ Savings = if ($_.AnnualSavings) { '{0:C0}/yr' -f [double]$_.AnnualSavings } else { '-' }
+ }
+ }
+ $cols = @('Category', 'Impact', 'Resource', 'Problem', 'Savings')
+ if ($data.TotalCount -gt 15) {
+ Write-Host " (showing top 15 of $($data.TotalCount) by savings)" -ForegroundColor DarkGray
+ }
+ }
+ default {
+ # Fallback: try to display as-is with first 4 properties
+ $items = @($data)
+ $sample = $items[0]
+ if ($sample.PSObject) {
+ $cols = $sample.PSObject.Properties.Name | Select-Object -First 4
+ $rows = $items
+ }
+ }
+ }
+
+ # Render the table
+ if ($rows -and @($rows).Count -gt 0) {
+ $validCols = $cols | Where-Object { $_ }
+ if ($validCols) {
+ # Budget Status: color each row by risk level
+ if ($mod.Fn -eq 'Get-BudgetStatus') {
+ $budgetRows = @($rows)
+ # Render header manually
+ $headerStr = @($budgetRows) | Select-Object $validCols | Format-Table -AutoSize | Out-String |
+ ForEach-Object { $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() } }
+ if ($headerStr.Count -ge 2) {
+ Write-Host " $($headerStr[0])" -ForegroundColor Cyan
+ Write-Host " $($headerStr[1])" -ForegroundColor DarkCyan
+ }
+ # Render each data row with risk-based color
+ for ($ri = 2; $ri -lt $headerStr.Count; $ri++) {
+ $budgetLine = $headerStr[$ri]
+ $matchedBudget = $null
+ if ($ri - 2 -lt $budgetRows.Count) { $matchedBudget = $budgetRows[$ri - 2] }
+ $riskVal = if ($matchedBudget -and $matchedBudget.Risk) { $matchedBudget.Risk } else { '' }
+ $rowColor = switch ($riskVal) {
+ 'Over Budget' { 'Red' }
+ 'At Risk' { 'Yellow' }
+ 'Watch' { 'DarkYellow' }
+ default { 'Green' }
+ }
+ Write-ColorizedLine -Text " $budgetLine" -DefaultColor $rowColor
+ }
+ }
+ else {
+ $tableLines = @($rows) | Select-Object $validCols | Format-Table -AutoSize | Out-String |
+ ForEach-Object { $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() } }
+ $headerDone = $false
+ foreach ($line in $tableLines) {
+ if (-not $headerDone) {
+ # First two lines are header + separator
+ if ($line -match '^[\s\-]+$') {
+ Write-Host " $line" -ForegroundColor DarkCyan
+ $headerDone = $true
+ }
+ else {
+ Write-Host " $line" -ForegroundColor Cyan
+ }
+ }
+ else {
+ Write-ColorizedLine -Text " $line" -DefaultColor 'White'
+ }
+ }
+ }
+ }
+ }
+ elseif (-not $rows) {
+ # Module used inline Write-Host (like SavingsRealized) — no table needed
+ }
+ else {
+ Write-Host " (no findings)" -ForegroundColor DarkGray
+ }
+
+ # -- Contextual Guidance ---------------------------------------
+ # Severity: Red = address immediately, Yellow = needs attention, Green = doing well
+ $guidanceItems = @()
+ switch ($mod.Fn) {
+ 'Get-OrphanedResources' {
+ $orphanCount = if ($data.Orphans) { @($data.Orphans).Count } else { 0 }
+ if ($orphanCount -gt 10) {
+ $categories = @($data.Orphans | ForEach-Object { $_.Category } | Sort-Object -Unique) -join ', '
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$orphanCount orphaned resources found ($categories). These generate cost with zero value." }
+ @{ Severity = 'Red'; Message = "FinOps Principle: Eliminate waste before optimizing. Orphaned resources are the easiest wins." }
+ @{ Severity = 'Yellow'; Message = "Set up Azure Policy to audit unattached disks, NICs, and public IPs to prevent future orphans." }
+ @{ Severity = 'Yellow'; Message = "Build a monthly cleanup cadence — orphans accumulate fast as teams scale up and down."; Docs = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations' }
+ )
+ }
+ elseif ($orphanCount -gt 0) {
+ $categories = @($data.Orphans | ForEach-Object { $_.Category } | Sort-Object -Unique) -join ', '
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$orphanCount orphaned resources found ($categories). Review and delete to reclaim spend." }
+ @{ Severity = 'Yellow'; Message = "Use Azure Policy to audit unattached disks and NICs going forward."; Docs = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "No orphaned resources. Environment is clean — good operational hygiene." }
+ )
+ }
+ }
+ 'Get-IdleVMs' {
+ $idleCount = if ($data.IdleVMs) { @($data.IdleVMs).Count } else { 0 }
+ $scanned = if ($data.ScannedVMs) { $data.ScannedVMs } else { 0 }
+ if ($idleCount -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$idleCount of $scanned VMs are idle or underutilized. This is likely significant wasted spend." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Check Azure Advisor for right-size recommendations before deleting — some may just need a smaller SKU." }
+ @{ Severity = 'Yellow'; Message = "For dev/test workloads, implement auto-shutdown schedules (saves 50-70% on non-production VMs)." }
+ @{ Severity = 'Yellow'; Message = "Consider Azure Spot VMs for fault-tolerant workloads — up to 90% discount vs. pay-as-you-go."; Docs = 'https://learn.microsoft.com/azure/virtual-machines/spot-vms' }
+ )
+ }
+ elseif ($idleCount -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$idleCount idle VMs detected. Right-size or deallocate to reduce spend." }
+ @{ Severity = 'Yellow'; Message = "Check Advisor for SKU recommendations. Auto-shutdown schedules help for dev/test."; Docs = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations#optimize-virtual-machine-spend' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "All $scanned running VMs are actively utilized. Compute spend looks healthy." }
+ )
+ }
+ }
+ 'Get-StorageTierAdvice' {
+ $recoCount = if ($data.Recommendations) { @($data.Recommendations).Count } else { 0 }
+ if ($recoCount -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$recoCount storage accounts can be moved to a cheaper tier (Cool or Cold saves 50-75%)." }
+ @{ Severity = 'Yellow'; Message = "FinOps Principle: Match storage tier to access patterns. Most data is written once and rarely read." }
+ @{ Severity = 'Yellow'; Message = "Enable lifecycle management policies to auto-tier blobs based on last access time — set it and forget it."; Docs = 'https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-overview' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "All storage accounts are appropriately tiered. Good data lifecycle management." }
+ )
+ }
+ }
+ 'Get-AHBOpportunities' {
+ $ahbCount = if ($rows) { @($rows).Count } else { 0 }
+ if ($ahbCount -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$ahbCount resources eligible for Azure Hybrid Benefit — up to 40% savings on Windows, 55% on SQL licensing." }
+ @{ Severity = 'Red'; Message = "FinOps Action: AHB is one of the highest-impact, lowest-effort optimizations. Apply to all eligible VMs and SQL resources." }
+ @{ Severity = 'Yellow'; Message = "Requires Software Assurance or qualifying subscription licenses. Check with your licensing team."; Docs = 'https://learn.microsoft.com/azure/virtual-machines/windows/hybrid-use-benefit-licensing' }
+ )
+ }
+ elseif ($ahbCount -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$ahbCount resources can use Azure Hybrid Benefit (up to 40% Windows / 55% SQL savings)." }
+ @{ Severity = 'Yellow'; Message = "Requires Software Assurance. Low effort to apply — high cost impact."; Docs = 'https://learn.microsoft.com/azure/virtual-machines/windows/hybrid-use-benefit-licensing' }
+ )
+ }
+ }
+ 'Get-TagInventory' {
+ $coverage = if ($data.TagCoverage) { $data.TagCoverage } else { 0 }
+ if ($coverage -lt 30) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Tag coverage is critically low at $coverage%." }
+ @{ Severity = 'Red'; Message = "FinOps Foundation: Tags are the #1 requirement for cost allocation. Without tags, you cannot do chargeback, showback, or unit economics." }
+ @{ Severity = 'Red'; Message = "Start with these 5 essential tags: CostCenter, Environment, Owner, Application, Department." }
+ @{ Severity = 'Yellow'; Message = "Use Azure Policy 'Require a tag and its value' to enforce tagging at deployment time." }
+ @{ Severity = 'Yellow'; Message = "Use 'Inherit a tag from the resource group' policy to auto-tag existing resources."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' }
+ )
+ }
+ elseif ($coverage -lt 50) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Tag coverage at $coverage% — below the minimum for reliable cost allocation." }
+ @{ Severity = 'Yellow'; Message = "FinOps requires 80%+ tag coverage for meaningful chargeback. Prioritize tagging high-cost resources first." }
+ @{ Severity = 'Yellow'; Message = "Essential tags: CostCenter, Environment, Owner, Application, Department." }
+ @{ Severity = 'Yellow'; Message = "Deploy tag inheritance policies to propagate subscription/RG tags to child resources."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' }
+ )
+ }
+ elseif ($coverage -lt 80) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Tag coverage at $coverage% — good progress, but target 80%+ for reliable cost allocation." }
+ @{ Severity = 'Yellow'; Message = "Focus on the highest-cost untagged resources. Use Cost Management views to find them." }
+ @{ Severity = 'Yellow'; Message = "Enable tag inheritance policies to auto-apply subscription/RG tags to new resources." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Tag coverage at $coverage% — strong tagging discipline." }
+ @{ Severity = 'Green'; Message = "Enable tag-based cost allocation in Cost Management to leverage your tags for chargeback." }
+ @{ Severity = 'Green'; Message = "Consider adding a 'Criticality' tag for incident response prioritization."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' }
+ )
+ }
+ }
+ 'Get-CostByTag' {
+ if ($data.CostByTag -and $data.CostByTag.Count -gt 0) {
+ # Use the MAX untagged cost across any single tag to avoid double-counting
+ # (the same resource appears as "(untagged)" under every tag it lacks)
+ $maxUntaggedCost = 0
+ $maxUntaggedTag = ''
+ foreach ($tag in $data.CostByTag.GetEnumerator()) {
+ foreach ($v in $tag.Value) {
+ if ($v.TagValue -eq '(untagged)' -and [double]$v.Cost -gt $maxUntaggedCost) {
+ $maxUntaggedCost = [double]$v.Cost
+ $maxUntaggedTag = $tag.Key
+ }
+ }
+ }
+ if ($maxUntaggedCost -gt 1000) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Untagged spend: $("{0:C0}" -f $maxUntaggedCost) (resources missing '$maxUntaggedTag'). This cost cannot be allocated to any team, project, or budget." }
+ @{ Severity = 'Red'; Message = "FinOps Impact: Untagged spend creates 'shadow IT' — no one owns it, no one optimizes it." }
+ @{ Severity = 'Yellow'; Message = "Use Cost Management tag views to identify the highest-cost untagged resources and tag them first." }
+ )
+ }
+ elseif ($maxUntaggedCost -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Some untagged spend detected ($("{0:C0}" -f $maxUntaggedCost) missing '$maxUntaggedTag'). Tag remaining resources for full cost traceability." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "All scanned cost is tagged. Cost allocation is fully traceable — enables chargeback and showback." }
+ )
+ }
+ }
+ elseif ($data.NoTagsFound) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "No tags exist to analyze cost against. Cost allocation is impossible without tags." }
+ @{ Severity = 'Red'; Message = "FinOps Foundation: Start with CostCenter, Environment, and Owner tags. These 3 enable basic chargeback/showback." }
+ @{ Severity = 'Yellow'; Message = "Run Tag Inventory first, then come back to Cost by Tag to see the financial impact."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' }
+ )
+ }
+ }
+ 'Get-CostTrend' {
+ if ($data.Months -and @($data.Months).Count -ge 2) {
+ $sorted = @($data.Months) | Sort-Object Month -Descending | Select-Object -First 2
+ $current = [double]$sorted[0].Cost
+ $previous = [double]$sorted[1].Cost
+ if ($previous -gt 0) {
+ $change = [math]::Round((($current - $previous) / $previous) * 100, 1)
+ if ($change -gt 20) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Cost spiked $change% month-over-month. Investigate immediately — this is abnormal growth." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Check for new deployments, usage spikes, or runaway auto-scale." }
+ @{ Severity = 'Yellow'; Message = "Set up Cost Management budget alerts at 80%, 90%, 100% to catch spikes early."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/cost-mgt-alerts-monitor-usage-spending' }
+ )
+ }
+ elseif ($change -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Cost increased $change% MoM. Moderate growth — review new resources deployed this period." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Establish a monthly cost review cadence to catch trends before they become problems." }
+ )
+ }
+ elseif ($change -lt -5) {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Cost decreased $([math]::Abs($change))% MoM. Optimization efforts are working." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Cost trend is stable ($change% change). Good cost discipline and predictable spend." }
+ )
+ }
+ }
+ }
+ }
+ 'Get-ReservationAdvice' {
+ if ($data.AdvisorRecommendations -and @($data.AdvisorRecommendations).Count -gt 0) {
+ $totalSavings = if ($data.EstimatedAnnualSavings) { $data.EstimatedAnnualSavings } else { 0 }
+ if ($totalSavings -gt 10000) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Significant reservation savings available: $("{0:C0}" -f $totalSavings)/year." }
+ @{ Severity = 'Red'; Message = "FinOps Principle: Commitment-based discounts (RIs, Savings Plans) are the single largest cost lever — typically 30-60% savings." }
+ @{ Severity = 'Yellow'; Message = "Start with 1-year terms for flexibility. Use shared scope to maximize utilization across subscriptions." }
+ @{ Severity = 'Yellow'; Message = "Review 14-day usage trends before purchasing to ensure steady-state workloads."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Reservation savings available: $("{0:C0}" -f $totalSavings)/year. Consider purchasing for steady-state workloads." }
+ @{ Severity = 'Yellow'; Message = "Start with 1-year terms. Use shared scope for best utilization."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' }
+ )
+ }
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "No reservation recommendations. Current commitment coverage appears sufficient." }
+ )
+ }
+ }
+ 'Get-CommitmentUtilization' {
+ if ($data.HasData) {
+ $riUtil = if ($data.RIAvgUtilization) { [double]$data.RIAvgUtilization } else { 100 }
+ $spUtil = if ($data.SPAvgUtilization) { [double]$data.SPAvgUtilization } else { 100 }
+ $lowUtil = ($riUtil -lt 80) -or ($spUtil -lt 80)
+ $medUtil = ($riUtil -lt 95) -or ($spUtil -lt 95)
+ if ($lowUtil) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Commitment utilization below 80%. You may be paying more than on-demand pricing." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Review scope and SKU alignment. Exchange underused RIs for better-fitting ones." }
+ @{ Severity = 'Yellow'; Message = "Target 95%+ utilization. Consider switching unused RIs to Savings Plans for more flexibility."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/manage-reserved-vm-instance' }
+ )
+ }
+ elseif ($medUtil) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Commitment utilization at RI: $($data.RIAvgUtilization)%, SP: $($data.SPAvgUtilization)%. Room to improve." }
+ @{ Severity = 'Yellow'; Message = "Review scope settings — broadening scope can improve utilization across subscriptions." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Commitment utilization is excellent (RI: $($data.RIAvgUtilization)%, SP: $($data.SPAvgUtilization)%). Maximum discount realized." }
+ )
+ }
+ }
+ }
+ 'Get-SavingsRealized' {
+ if ($data.TotalMonthly -and $data.TotalMonthly -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Realizing $($data.TotalMonthly.ToString('C0'))/month ($($data.TotalAnnual.ToString('C0'))/year) in commitment discounts." }
+ @{ Severity = 'Green'; Message = "FinOps Maturity: Active savings tracking shows Run-level FinOps maturity. Keep reviewing quarterly." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "No savings from commitments detected. Evaluate RIs and Savings Plans for steady-state workloads." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Commitment discounts are the #1 cost optimization lever (30-60% savings)."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' }
+ )
+ }
+ }
+ 'Get-BudgetStatus' {
+ $atRisk = if ($data.AtRiskCount) { $data.AtRiskCount } else { 0 }
+ $over = if ($data.OverBudgetCount) { $data.OverBudgetCount } else { 0 }
+ $bCoverage = if ($data.BudgetCoverage) { $data.BudgetCoverage } else { 0 }
+ if ($over -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$over budget(s) exceeded. Immediate review needed — spending is above approved levels." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Identify the cause (new deployments, usage spike, missing commitment) and remediate." }
+ @{ Severity = 'Yellow'; Message = "Add action groups with alerts at 80%, 90%, 100% thresholds to catch overruns earlier next period."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' }
+ )
+ }
+ elseif ($atRisk -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$atRisk budget(s) at risk of overrun. Review forecasted spend vs. remaining budget." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Proactive budget monitoring prevents end-of-period surprises. Consider cost reduction now." }
+ )
+ }
+ elseif ($bCoverage -lt 50) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "Budget coverage is only $bCoverage%. Most subscriptions have no budget — spending is untracked." }
+ @{ Severity = 'Red'; Message = "FinOps Foundation: Budgets are the starting point for cost accountability. Without them, there's no alerting, no forecasting, no governance." }
+ @{ Severity = 'Yellow'; Message = "Create a budget for every subscription. Start with last month's actual spend + 10% buffer."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Budgets are healthy. All within thresholds with $bCoverage% coverage. Good financial governance." }
+ )
+ }
+ }
+ 'Get-AnomalyAlerts' {
+ $activeAlerts = if ($data.ActiveAlertCount) { $data.ActiveAlertCount } else { 0 }
+ $rules = if ($data.ConfiguredRuleCount) { $data.ConfiguredRuleCount } else { 0 }
+ if ($activeAlerts -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$activeAlerts active anomaly alerts. Review to determine if they indicate unexpected spend patterns." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Cost anomaly detection is an early warning system. Investigate anomalies promptly." }
+ )
+ }
+ elseif ($rules -eq 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "No anomaly detection rules configured. Set up Cost Management anomaly alerts for early spend warnings." }
+ @{ Severity = 'Yellow'; Message = "Anomaly detection is built into Azure Cost Management at no extra cost."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/understand/analyze-unexpected-charges' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Anomaly detection is configured with $rules rule(s) and no active alerts. Monitoring is working." }
+ )
+ }
+ }
+ 'Get-PolicyInventory' {
+ $compliance = if ($data.CompliancePct) { $data.CompliancePct } else { 0 }
+ $nonCompliant = if ($data.TotalNonCompliant) { $data.TotalNonCompliant } else { 0 }
+ if ($nonCompliant -gt 20) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$nonCompliant non-compliant resources ($compliance% compliance). Governance gaps are significant." }
+ @{ Severity = 'Yellow'; Message = "FinOps Governance: Use 'Deny' for critical policies (e.g., required tags). Use 'Audit' first during rollout." }
+ @{ Severity = 'Yellow'; Message = "Create remediation tasks for existing non-compliant resources."; Docs = 'https://learn.microsoft.com/azure/governance/policy/how-to/remediate-resources' }
+ )
+ }
+ elseif ($nonCompliant -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$nonCompliant non-compliant resources ($compliance% compliance). Review and remediate or create exemptions." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Full policy compliance ($compliance%). Strong governance posture." }
+ )
+ }
+ }
+ 'Get-PolicyRecommendations' {
+ if ($data.Analysis -and @($data.Analysis).Count -gt 0) {
+ $missing = @($data.Analysis | Where-Object { $_.Status -eq 'Missing' })
+ if ($missing.Count -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$($missing.Count) recommended FinOps policies are not assigned. Governance foundation is incomplete." }
+ @{ Severity = 'Yellow'; Message = "Priority policies: tag enforcement, allowed VM SKUs, allowed locations, resource naming." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: Policy-driven governance prevents cost waste at deployment time — cheaper than cleanup."; Docs = 'https://learn.microsoft.com/azure/governance/policy/samples/built-in-policies' }
+ )
+ }
+ elseif ($missing.Count -gt 0) {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$($missing.Count) recommended policies not yet assigned. Review and deploy as needed." }
+ @{ Severity = 'Yellow'; Message = "Start with: tag enforcement, allowed locations, and allowed VM SKUs."; Docs = 'https://learn.microsoft.com/azure/governance/policy/samples/built-in-policies' }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "All recommended FinOps policies are assigned. Strong governance foundation." }
+ )
+ }
+ }
+ }
+ 'Get-OptimizationAdvice' {
+ if ($data.Recommendations -and @($data.Recommendations).Count -gt 0) {
+ $highImpact = @($data.Recommendations | Where-Object { $_.Impact -eq 'High' })
+ if ($highImpact.Count -gt 5) {
+ $guidanceItems = @(
+ @{ Severity = 'Red'; Message = "$($highImpact.Count) high-impact Advisor recommendations. Significant savings available." }
+ @{ Severity = 'Red'; Message = "FinOps Action: Start with high-impact items — they offer the largest return for effort." }
+ @{ Severity = 'Yellow'; Message = "Dismiss recommendations you've evaluated to keep the list actionable. Review monthly." }
+ )
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "$(@($data.Recommendations).Count) Advisor recommendations ($($highImpact.Count) high-impact). Review and prioritize." }
+ @{ Severity = 'Yellow'; Message = "Dismiss evaluated items to keep the list clean. Azure Advisor refreshes daily." }
+ )
+ }
+ }
+ else {
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "No Advisor cost recommendations. Environment is well optimized." }
+ )
+ }
+ }
+ 'Get-CostData' {
+ if ($data -is [hashtable] -and $data.Count -gt 0) {
+ $totalActual = 0
+ foreach ($sub in $data.GetEnumerator()) { $totalActual += [double]$sub.Value.Actual }
+ $guidanceItems = @(
+ @{ Severity = 'Green'; Message = "Current period spend: $("{0:C0}" -f $totalActual) across $($data.Count) subscription(s)." }
+ @{ Severity = 'Green'; Message = "FinOps Practice: Review actual vs. forecast regularly. Pair this data with Budget Status to track variance." }
+ )
+ }
+ }
+ 'Get-ResourceCosts' {
+ $topCount = if ($data) { @($data).Count } else { 0 }
+ if ($topCount -gt 0) {
+ $topCost = [double](@($data) | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 1).Actual
+ $guidanceItems = @(
+ @{ Severity = 'Yellow'; Message = "Top resource costs $("{0:C2}" -f $topCost). Focus optimization on the largest cost drivers." }
+ @{ Severity = 'Yellow'; Message = "FinOps Practice: The top 20% of resources typically drive 80% of spend. Optimize these first." }
+ )
+ }
+ }
+ }
+
+ # Render guidance with severity colors
+ if ($guidanceItems.Count -gt 0) {
+ Write-Host ""
+ # Determine overall severity for the header
+ $hasCritical = $guidanceItems | Where-Object { $_.Severity -eq 'Red' }
+ $hasWarning = $guidanceItems | Where-Object { $_.Severity -eq 'Yellow' }
+ $headerColor = if ($hasCritical) { 'Red' } elseif ($hasWarning) { 'Yellow' } else { 'Green' }
+ $headerIcon = switch ($headerColor) { 'Red' { '[!]' } 'Yellow' { '[~]' } 'Green' { '[+]' } }
+ Write-Host " $headerIcon GUIDANCE" -ForegroundColor $headerColor
+
+ foreach ($item in $guidanceItems) {
+ $color = switch ($item.Severity) { 'Red' { 'Red' } 'Yellow' { 'DarkYellow' } 'Green' { 'Green' } default { 'Gray' } }
+ $icon = switch ($item.Severity) { 'Red' { '!' } 'Yellow' { '~' } 'Green' { '+' } default { '-' } }
+ Write-Host " $icon $($item.Message)" -ForegroundColor $color
+ if ($item.Docs) {
+ Write-Host " $($item.Docs)" -ForegroundColor DarkCyan
+ }
+ }
+ }
+
+ Write-Host ""
+ }
+
+ # -- Export option -------------------------------------------------
+ if ($ExportPath) {
+ $exportDir = $ExportPath
+ }
+ else {
+ $defaultPath = Join-Path (Get-Location) 'FinOpsResults'
+ Write-Host " Export results? [E] Export [Enter] Skip" -ForegroundColor DarkGray
+ $eKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
+ if ($eKey.Character -eq 'e' -or $eKey.Character -eq 'E') {
+ Write-Host ""
+ Write-Host " Path [$defaultPath]: " -ForegroundColor White -NoNewline
+ $exportDir = Read-Host
+ if (-not $exportDir -or $exportDir.Trim() -eq '') {
+ $exportDir = $defaultPath
+ }
+ }
+ else {
+ $exportDir = $null
+ }
+ }
+
+ if ($exportDir -and $exportDir.Trim() -ne '') {
+ if (-not (Test-Path $exportDir)) {
+ New-Item -ItemType Directory -Path $exportDir -Force | Out-Null
+ }
+
+ # -- CSV exports per module --
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $data = $Results[$mod.Fn]
+ if ($data -and @($data).Count -gt 0) {
+ $safeName = $mod.Fn -replace '[^a-zA-Z0-9\-]', ''
+ $csvPath = Join-Path $exportDir "$safeName.csv"
+ $data | Export-Csv -Path $csvPath -NoTypeInformation
+ }
+ }
+
+ # -- HTML report --
+ $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
+ $subList = if ($Subscriptions) { ($Subscriptions | ForEach-Object { if ($_.Name) { $_.Name } else { $_.Id } }) -join ', ' } else { 'N/A' }
+ $htmlSb = [System.Text.StringBuilder]::new()
+ [void]$htmlSb.Append(@"
+
+
+
+
+FinOps Multitool Report — $timestamp
+
+
+
+FinOps Multitool Report
+Generated: $timestamp | Subscriptions: $([System.Net.WebUtility]::HtmlEncode($subList))
+"@)
+
+ # Summary cards
+ $errorCount = ($Modules | Where-Object { $_.Selected } | Where-Object { $Results.ContainsKey("_error_$($_.Fn)") }).Count
+ [void]$htmlSb.Append('')
+ [void]$htmlSb.Append("
Total Findings
$totalFindings
")
+ [void]$htmlSb.Append("
Scans Run
$(($Modules | Where-Object { $_.Selected }).Count)
")
+ if ($errorCount -gt 0) {
+ [void]$htmlSb.Append("
")
+ }
+ [void]$htmlSb.Append('
')
+
+ # Per-module sections
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $fn = $mod.Fn
+ $data = $Results[$fn]
+ $eName = [System.Net.WebUtility]::HtmlEncode($mod.Name)
+ [void]$htmlSb.Append("$eName ")
+
+ $errorKey = "_error_$fn"
+ if ($Results.ContainsKey($errorKey)) {
+ $eMsg = [System.Net.WebUtility]::HtmlEncode($Results[$errorKey])
+ [void]$htmlSb.Append("Error: $eMsg")
+ if ($permissionInfo.ContainsKey($fn)) {
+ $pi = $permissionInfo[$fn]
+ [void]$htmlSb.Append("Required: $([System.Net.WebUtility]::HtmlEncode($pi.Role)) at $([System.Net.WebUtility]::HtmlEncode($pi.Scope)) scope ($([System.Net.WebUtility]::HtmlEncode($pi.API))) ")
+ }
+ [void]$htmlSb.Append('
')
+ continue
+ }
+
+ if (-not $data -or @($data).Count -eq 0) {
+ $noDataMsg = 'No data returned.'
+ if ($permissionInfo.ContainsKey($fn)) {
+ $noDataMsg += " $($permissionInfo[$fn].Reason)"
+ }
+ [void]$htmlSb.Append("$([System.Net.WebUtility]::HtmlEncode($noDataMsg))
")
+ continue
+ }
+
+ # Render module-specific summaries + table
+ $htmlRows = $null
+ $htmlCols = $null
+ switch ($fn) {
+ 'Get-OrphanedResources' {
+ $htmlRows = $data.Orphans
+ $htmlCols = @('Category', 'ResourceName', 'ResourceGroup', 'Detail')
+ }
+ 'Get-IdleVMs' {
+ [void]$htmlSb.Append("Scanned $($data.ScannedVMs) running VMs
")
+ $htmlRows = $data.IdleVMs
+ $htmlCols = @('VMName', 'ResourceGroup', 'VMSize', 'AvgCPU14d', 'Classification')
+ }
+ 'Get-StorageTierAdvice' {
+ [void]$htmlSb.Append("$($data.TotalHotAccounts) Hot-tier accounts scanned
")
+ $htmlRows = $data.Recommendations
+ $htmlCols = @('StorageAccount', 'ResourceGroup', 'CurrentTier', 'CapacityGB', 'Recommendation')
+ }
+ 'Get-AHBOpportunities' {
+ $ahbRows = @()
+ if ($data.WindowsVMs) { $ahbRows += @($data.WindowsVMs) | ForEach-Object { [PSCustomObject]@{ Type = 'Windows VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.vmSize; License = $_.currentLicense } } }
+ if ($data.SQLVMs) { $ahbRows += @($data.SQLVMs) | ForEach-Object { [PSCustomObject]@{ Type = 'SQL VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sqlEdition; License = $_.currentLicense } } }
+ if ($data.SQLDatabases) { $ahbRows += @($data.SQLDatabases) | ForEach-Object { [PSCustomObject]@{ Type = 'SQL DB'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sku; License = $_.currentLicense } } }
+ $htmlRows = $ahbRows
+ $htmlCols = @('Type', 'Name', 'ResourceGroup', 'Size', 'License')
+ }
+ 'Get-TagInventory' {
+ [void]$htmlSb.Append("Coverage: $($data.TagCoverage)% | $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged | $($data.TagCount) unique tags
")
+ if ($data.TagNames) {
+ $htmlRows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object {
+ [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; UniqueValues = @($_.Value.Values).Count }
+ }
+ $htmlCols = @('Tag', 'Resources', 'UniqueValues')
+ }
+ }
+ 'Get-CostData' {
+ if ($data -is [hashtable]) {
+ $htmlRows = $data.GetEnumerator() | ForEach-Object {
+ $sl = if ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } else { $_.Key }
+ [PSCustomObject]@{ Subscription = $sl; Actual = '{0:C0}' -f [double]$_.Value.Actual; Forecast = '{0:C0}' -f [double]$_.Value.Forecast; Currency = $_.Value.Currency }
+ }
+ $htmlCols = @('Subscription', 'Actual', 'Forecast', 'Currency')
+ }
+ }
+ 'Get-ResourceCosts' {
+ $htmlRows = @($data) | Sort-Object { $_.Actual } -Descending | Select-Object -First 50 | ForEach-Object {
+ [PSCustomObject]@{ ResourceGroup = $_.ResourceGroup; ResourceType = ($_.ResourceType -split '/')[-1]; Cost = '{0:C2}' -f [double]$_.Actual }
+ }
+ $htmlCols = @('ResourceGroup', 'ResourceType', 'Cost')
+ }
+ 'Get-CostByTag' {
+ if ($data.CostByTag) {
+ $htmlRows = foreach ($tag in $data.CostByTag.GetEnumerator()) {
+ foreach ($v in $tag.Value) {
+ [PSCustomObject]@{ Tag = $tag.Key; Value = $v.TagValue; Cost = '{0:C0}' -f [double]$v.Cost }
+ }
+ }
+ $htmlCols = @('Tag', 'Value', 'Cost')
+ }
+ }
+ 'Get-CostTrend' {
+ if ($data.Months) {
+ $htmlRows = $data.Months | ForEach-Object { [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency } }
+ $htmlCols = @('Month', 'Cost', 'Currency')
+ }
+ }
+ 'Get-ReservationAdvice' {
+ if ($data.EstimatedAnnualSavings) { [void]$htmlSb.Append("Est. annual savings: `$$($data.EstimatedAnnualSavings.ToString('N0'))
") }
+ $htmlRows = $data.AdvisorRecommendations | ForEach-Object {
+ [PSCustomObject]@{ Resource = ($_.ResourceName -split '/')[-1]; Type = ($_.ResourceType -split '/')[-1]; Term = $_.Term; Savings = '{0:C0}' -f [double]$_.AnnualSavings; Impact = $_.Impact }
+ }
+ $htmlCols = @('Resource', 'Type', 'Term', 'Savings', 'Impact')
+ }
+ 'Get-CommitmentUtilization' {
+ if ($data.HasData) {
+ [void]$htmlSb.Append("RIs: $($data.RICount) (avg $($data.RIAvgUtilization)%) | Savings Plans: $($data.SPCount) (avg $($data.SPAvgUtilization)%)
")
+ $htmlRows = $data.UnderutilizedRIs | ForEach-Object { [PSCustomObject]@{ SKU = $_.SkuName; Kind = $_.Kind; AvgUtil = "$($_.AvgUtilization)%"; MinUtil = "$($_.MinUtilization)%" } }
+ $htmlCols = @('SKU', 'Kind', 'AvgUtil', 'MinUtil')
+ }
+ }
+ 'Get-SavingsRealized' {
+ [void]$htmlSb.Append("RI: $($data.RISavingsMonthly.ToString('C0')) | SP: $($data.SPSavingsMonthly.ToString('C0')) | AHB: $($data.AHBSavingsMonthly.ToString('C0')) | Total: $($data.TotalMonthly.ToString('C0'))/mo
")
+ }
+ 'Get-BudgetStatus' {
+ [void]$htmlSb.Append("Budgets: $($data.TotalBudgets) | At risk: $($data.AtRiskCount) | Over budget: $($data.OverBudgetCount) | Coverage: $($data.BudgetCoverage)%
")
+ $htmlRows = $data.Budgets | ForEach-Object {
+ $riskClass = switch ($_.Risk) { 'Over Budget' { 'severity-red' } 'At Risk' { 'severity-yellow' } 'Watch' { 'severity-yellow' } default { 'severity-green' } }
+ [PSCustomObject]@{ Budget = $_.BudgetName; Amount = '{0:C0}' -f [double]$_.Amount; Spent = '{0:C0}' -f [double]$_.ActualSpend; PctUsed = "$($_.PctUsed)%"; Risk = $_.Risk; _riskClass = $riskClass }
+ }
+ $htmlCols = @('Budget', 'Amount', 'Spent', 'PctUsed', 'Risk')
+ }
+ 'Get-AnomalyAlerts' {
+ [void]$htmlSb.Append("Total: $($data.TotalAlerts) | Anomaly: $($data.AnomalyAlertCount) | Active: $($data.ActiveAlertCount)
")
+ $htmlRows = $data.TriggeredAlerts | Select-Object -First 10 | ForEach-Object {
+ [PSCustomObject]@{ Alert = $_.AlertName; Type = $_.AlertType; Status = $_.Status; Subscription = $_.Subscription }
+ }
+ $htmlCols = @('Alert', 'Type', 'Status', 'Subscription')
+ }
+ 'Get-OptimizationAdvice' {
+ if ($data.EstimatedAnnualSavings) { [void]$htmlSb.Append("Est. annual savings: `$$($data.EstimatedAnnualSavings) | $($data.TotalCount) recommendations
") }
+ $htmlRows = $data.Recommendations | Sort-Object { if ($_.AnnualSavings) { [double]$_.AnnualSavings } else { 0 } } -Descending | Select-Object -First 25 | ForEach-Object {
+ [PSCustomObject]@{ Category = $_.Category; Impact = $_.Impact; Resource = $_.ResourceName; Problem = ($_.Problem -replace '(.{80}).+', '$1...'); Savings = if ($_.AnnualSavings) { '{0:C0}/yr' -f [double]$_.AnnualSavings } else { '-' } }
+ }
+ $htmlCols = @('Category', 'Impact', 'Resource', 'Problem', 'Savings')
+ }
+ 'Get-TagRecommendations' {
+ $htmlRows = $data.Analysis | ForEach-Object { [PSCustomObject]@{ Tag = $_.TagName; Status = $_.Status; Priority = $_.Priority; Pillar = $_.Pillar; Example = $_.Example } }
+ $htmlCols = @('Tag', 'Status', 'Priority', 'Pillar', 'Example')
+ }
+ 'Get-PolicyInventory' {
+ $htmlRows = $data.Assignments | ForEach-Object { [PSCustomObject]@{ Name = $_.AssignmentName; Effect = $_.Effect; Enforcement = $_.EnforcementMode; Scope = $_.Scope } }
+ $htmlCols = @('Name', 'Effect', 'Enforcement', 'Scope')
+ }
+ 'Get-PolicyRecommendations' {
+ $htmlRows = $data.Analysis | ForEach-Object { [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect } }
+ $htmlCols = @('Policy', 'Status', 'Category', 'Priority', 'Effect')
+ }
+ 'Get-BillingStructure' {
+ $htmlRows = $data.BillingAccounts | ForEach-Object { [PSCustomObject]@{ Account = $_.DisplayName; Agreement = $_.AgreementType; Type = $_.AccountType; Status = $_.AccountStatus } }
+ $htmlCols = @('Account', 'Agreement', 'Type', 'Status')
+ }
+ 'Get-ContractInfo' {
+ $htmlRows = @($data) | ForEach-Object { [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Currency = $_.Currency; Status = $_.AccountStatus } }
+ $htmlCols = @('Account', 'Agreement', 'Type', 'Currency', 'Status')
+ }
+ }
+
+ # Render HTML table
+ if ($htmlRows -and $htmlCols) {
+ [void]$htmlSb.Append('')
+ foreach ($c in $htmlCols) { [void]$htmlSb.Append("$([System.Net.WebUtility]::HtmlEncode($c)) ") }
+ [void]$htmlSb.Append(' ')
+ foreach ($r in $htmlRows) {
+ [void]$htmlSb.Append('')
+ foreach ($c in $htmlCols) {
+ $val = $r.$c
+ $enc = [System.Net.WebUtility]::HtmlEncode([string]$val)
+ # Colorize money values and risk/severity
+ if ($enc -match '^\$') { $enc = "$enc " }
+ if ($c -eq 'Risk' -and $r.PSObject.Properties['_riskClass']) { $enc = "$enc " }
+ if ($c -eq 'Impact') {
+ $impClass = switch ($val) { 'High' { 'severity-red' } 'Medium' { 'severity-yellow' } default { 'severity-green' } }
+ $enc = "$enc "
+ }
+ [void]$htmlSb.Append("$enc ")
+ }
+ [void]$htmlSb.Append(' ')
+ }
+ [void]$htmlSb.Append('
')
+ }
+
+ # Render guidance
+ # (Re-evaluate guidance items for HTML — reuse the same logic)
+ }
+
+ [void]$htmlSb.Append('')
+ [void]$htmlSb.Append('')
+
+ $htmlPath = Join-Path $exportDir 'FinOpsReport.html'
+ $htmlSb.ToString() | Out-File -FilePath $htmlPath -Encoding utf8
+
+ # Summary text file
+ $summaryPath = Join-Path $exportDir 'ScanSummary.txt'
+ $summaryLines = @(
+ "FinOps Multitool Scan Summary"
+ "Generated: $timestamp"
+ "Subscriptions: $subList"
+ "Total findings: $totalFindings"
+ ""
+ )
+ foreach ($mod in ($Modules | Where-Object { $_.Selected })) {
+ $count = if ($Results[$mod.Fn]) { @($Results[$mod.Fn]).Count } else { 0 }
+ $errorKey = "_error_$($mod.Fn)"
+ $status = if ($Results.ContainsKey($errorKey)) { "ERROR: $($Results[$errorKey])" } elseif ($count -eq 0) { "No data" } else { "$count findings" }
+ $summaryLines += "$($mod.Name): $status"
+ }
+ $summaryLines | Out-File -FilePath $summaryPath -Encoding utf8
+
+ Write-Host ""
+ Write-Host " Exported to: $exportDir" -ForegroundColor Green
+ $csvCount = (Get-ChildItem $exportDir -Filter '*.csv').Count
+ Write-Host " Files: $csvCount CSVs + FinOpsReport.html + ScanSummary.txt" -ForegroundColor DarkGray
+ }
+
+ # Interactive drill-down
+ Write-Host ""
+ Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray
+ Write-Host " Results are stored in `$FinOpsResults. Examples:" -ForegroundColor DarkGray
+ Write-Host ' $FinOpsResults["Get-OrphanedResources"] | Format-Table' -ForegroundColor DarkGray
+ Write-Host ' $FinOpsResults["Get-IdleVMs"] | Where-Object Impact -eq "High"' -ForegroundColor DarkGray
+ Write-Host ""
+
+ return $Results
+ }
+
+ # =====================================================================
+ # MAIN FLOW
+ # =====================================================================
+ Show-Banner
+
+ # Step 1: Connect & pick subscription
+ $subs = Select-Subscription -PreselectedId $SubscriptionId
+ if (-not $subs) {
+ Write-Host " Cancelled." -ForegroundColor Yellow
+ return
+ }
+
+ # Capture tenant ID from current context
+ $tenantId = (Get-AzContext).Tenant.Id
+
+ # Step 2: Pick data source
+ $dataSource = Select-DataSource -TenantId $tenantId -Subscriptions $subs
+
+ # If "Resource Graph only", disable cost modules
+ $costModuleFns = @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-CostTrend',
+ 'Get-SavingsRealized', 'Get-CommitmentUtilization', 'Get-ReservationAdvice',
+ 'Get-BudgetStatus', 'Get-AnomalyAlerts', 'Get-BillingStructure', 'Get-ContractInfo')
+ if ($dataSource.Source -eq 'GraphOnly') {
+ foreach ($mod in $scanModules) {
+ if ($mod.Fn -in $costModuleFns) { $mod.Selected = $false }
+ }
+ }
+
+ # Show active data source
+ $sourceLabel = switch ($dataSource.Source) {
+ 'Hub' { "FinOps Hub ($($dataSource.HubStorage.name))" }
+ 'API' { 'Cost Management API (real-time)' }
+ 'GraphOnly' { 'Resource Graph only (no cost data)' }
+ }
+ $sourceColor = switch ($dataSource.Source) { 'Hub' { 'Green' } 'API' { 'Yellow' } 'GraphOnly' { 'DarkGray' } }
+ Write-Host ""
+ Write-Host " Data source: $sourceLabel" -ForegroundColor $sourceColor
+ Write-Host ""
+
+ # Step 3: Pick scans
+ $finalModules = Select-ScanModules -Modules $scanModules
+ if (-not $finalModules) {
+ Write-Host " Cancelled." -ForegroundColor Yellow
+ return
+ }
+
+ # Auto-enable dependencies
+ $selected = $finalModules | Where-Object { $_.Selected }
+ $selectedFns = $selected.Fn
+ $deps = @{
+ 'Get-CostByTag' = @('Get-CostData', 'Get-TagInventory')
+ 'Get-TagRecommendations' = @('Get-TagInventory')
+ 'Get-PolicyRecommendations' = @('Get-PolicyInventory')
+ 'Get-BudgetStatus' = @('Get-CostData')
+ }
+ foreach ($depEntry in $deps.GetEnumerator()) {
+ if ($depEntry.Key -in $selectedFns) {
+ foreach ($req in $depEntry.Value) {
+ if ($req -notin $selectedFns) {
+ $mod = $finalModules | Where-Object { $_.Fn -eq $req }
+ if ($mod) {
+ $mod.Selected = $true
+ Write-Host " Auto-enabled: $($mod.Name) (required by $($depEntry.Key -replace 'Get-',''))" -ForegroundColor DarkGray
+ }
+ }
+ }
+ }
+ }
+
+ # Step 4: Run
+ $results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $dataSource
+
+ # Step 5: Summary + export
+ $global:FinOpsResults = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs
+
+ Write-Host " Done. Results available in `$FinOpsResults" -ForegroundColor Green
+ Write-Host ""
+}
+
+# Auto-invoke when run directly (not dot-sourced or imported as module)
+if ($MyInvocation.InvocationName -ne '.') {
+ Invoke-FinOpsMultitool @PSBoundParameters
+}
diff --git a/src/powershell/Private/FinOpsMultitool/LICENSE b/src/powershell/Private/FinOpsMultitool/LICENSE
new file mode 100644
index 000000000..dd0ab5585
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Zac Larsen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md
new file mode 100644
index 000000000..2460fdb71
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/README.md
@@ -0,0 +1,279 @@
+# FinOps Multitool — Terminal UI (TUI)
+
+Interactive terminal interface for running FinOps scans against Azure subscriptions. No GUI dependencies — works in any terminal on Windows, macOS, and Linux.
+
+## Quick Start
+
+```powershell
+# From the FinOpsMultitool directory
+Import-Module .\FinOpsMultitool.psm1
+Invoke-FinOpsMultitool
+```
+
+Or target a specific subscription:
+
+```powershell
+Invoke-FinOpsMultitool -SubscriptionId '2693c348-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
+```
+
+## Requirements
+
+| Requirement | Details |
+| --------------------- | --------------------------------------------------------------- |
+| PowerShell | 5.1+ (Windows) or 7+ (cross-platform) |
+| Az modules | `Az.Accounts`, `Az.Resources`, `Az.ResourceGraph`, `Az.Storage` |
+| Azure RBAC | Reader + Cost Management Reader on target scope |
+| FinOps Hub (optional) | Storage Blob Data Reader on Hub storage account |
+
+Install Az modules if needed:
+
+```powershell
+Install-Module Az.Accounts, Az.Resources, Az.ResourceGraph, Az.Storage -Scope CurrentUser
+```
+
+## How It Works
+
+### 1. Authentication
+
+On launch, the TUI checks for an existing `Az.Accounts` session. If you're not logged in, it prompts you to run `Connect-AzAccount`. If your account has access to multiple Azure AD tenants, a tenant picker appears so you can select which tenant to scan. It then discovers all accessible subscriptions and lets you select which ones to scan.
+
+### 2. Data Source Selection
+
+If a FinOps Hub is detected in any of your subscriptions, you'll be asked to choose a data source:
+
+| Source | Description |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| **FinOps Hub** | Reads pre-exported FOCUS cost data from Hub storage. Faster, no API throttling. Tag and cost-by-tag scans are instant. |
+| **Cost Management API** | Queries the Cost Management REST API in real-time. Slower but always current. Hub tag data is still used when available. |
+| **Resource Graph only** | Skips all cost APIs. Only runs scans that use Azure Resource Graph (orphaned resources, idle VMs, etc). |
+
+### 3. Scan Selection
+
+Arrow-key driven menu to toggle individual scans on/off. All scans are selected by default except Billing Structure.
+
+| Key | Action |
+| --------- | ------------------ |
+| `↑` / `↓` | Navigate scan list |
+| `Space` | Toggle scan on/off |
+| `A` | Select all |
+| `N` | Deselect all |
+| `Enter` | Run selected scans |
+| `Q` | Quit |
+
+### 4. Scan Execution
+
+Selected scans run sequentially with a progress bar. When a FinOps Hub is available, tag-related scans (Tag Inventory, Cost by Tag) use pre-loaded Hub data instead of API calls — completing in under a second.
+
+### 5. Results
+
+Results display inline with formatted tables, severity-colored guidance, and permission diagnostics.
+
+**Guidance system** — After each scan result, contextual FinOps guidance appears with severity-based coloring:
+
+| Icon | Color | Meaning |
+| ----- | ------ | ---------------------------------- |
+| `[!]` | Red | Critical finding — action required |
+| `[~]` | Yellow | Warning — improvement recommended |
+| `[+]` | Green | Healthy — good practices confirmed |
+
+Guidance includes FinOps Foundation best practices, actionable next steps, and links to Microsoft Learn documentation.
+
+**Dollar colorization** — All dollar amounts in results are highlighted in green for quick scanning. Budget rows are colored by risk severity (red for over budget, yellow for at risk, green for on track).
+
+**Permission diagnostics** — When a scan returns no data, the TUI explains why:
+
+- **Access denied** (403/401) — Shows the exact error, required RBAC role, scope, and API
+- **No data** — Explains whether the module requires specific resources (e.g., "Returns empty if no budgets are configured")
+
+An optional CSV/JSON export saves to the output path.
+
+## Required Permissions
+
+Each scan module requires specific Azure RBAC roles. The TUI will tell you which role is needed if a scan fails due to missing permissions.
+
+| Category | Scans | Required Role | Scope |
+| ------------ | ------------------------------------------------------------ | ------------------------ | ------------------- |
+| Optimization | Orphaned Resources, Idle VMs, Storage Tier Advice, AHB | Reader | Subscription |
+| Governance | Tag Inventory, Tag Recommendations, Policy Inventory/Recs | Reader | Subscription |
+| Cost | Cost Data, Resource Costs, Cost by Tag, Cost Trend | Cost Management Reader | Subscription or MG |
+| Commitments | Reservation Advice, Commitment Utilization, Savings Realized | Cost Management Reader | Subscription |
+| Monitoring | Budget Status, Anomaly Alerts | Cost Management Reader | Subscription |
+| Advisor | Optimization Advice | Reader | Subscription |
+| Account | Billing Structure, Contract Info | Billing Reader | Billing Account |
+| Hub (opt.) | All scans via Hub data | Storage Blob Data Reader | Hub Storage Account |
+
+## Available Scans
+
+### Optimization (Resource Graph)
+
+| Scan | What it finds |
+| ------------------- | ---------------------------------------------- |
+| Orphaned Resources | Unattached disks, NICs, public IPs, NSGs |
+| Idle VMs | VMs with <5% CPU over 30 days |
+| Storage Tier Advice | Blob storage that could move to cooler tiers |
+| AHB Opportunities | Windows/SQL VMs not using Azure Hybrid Benefit |
+
+### Governance
+
+| Scan | What it finds |
+| ---------------------- | --------------------------------------------------------- |
+| Tag Inventory | All tags across resources — names, values, coverage % |
+| Tag Recommendations | Inconsistent casing, similar names, missing standard tags |
+| Policy Inventory | Azure Policy assignments with scope and compliance |
+| Policy Recommendations | Gaps in policy coverage for cost governance |
+
+### Cost Analysis
+
+| Scan | What it finds |
+| -------------- | --------------------------------- |
+| Cost Data | Monthly spend per subscription |
+| Resource Costs | Top resources by cost |
+| Cost by Tag | Spend breakdown by tag key/value |
+| Cost Trend | Month-over-month spend comparison |
+
+### Commitments
+
+| Scan | What it finds |
+| ---------------------- | ---------------------------------------- |
+| Reservation Advice | RI purchase recommendations from Advisor |
+| Commitment Utilization | RI and Savings Plan usage rates |
+| Savings Realized | Actual savings from existing commitments |
+
+### Monitoring
+
+| Scan | What it finds |
+| -------------- | --------------------------------- |
+| Budget Status | Budget consumption vs. thresholds |
+| Anomaly Alerts | Recent cost anomaly detections |
+
+### Advisor & Account
+
+| Scan | What it finds |
+| ------------------- | ---------------------------------------- |
+| Optimization Advice | Azure Advisor cost recommendations |
+| Billing Structure | Account hierarchy and enrollment details |
+| Contract Info | Agreement type, offer, support plan |
+
+## FinOps Hub Integration
+
+When a Hub is detected, the TUI automatically loads FOCUS cost data from the Hub's storage account. This enables:
+
+- **Instant tag scans** — Tag Inventory and Cost by Tag read directly from Hub CSV/parquet data instead of querying the Cost Management API
+- **No API throttling** — Hub data is read from Azure Storage, avoiding Cost Management API rate limits
+- **Richer tag data** — Hub exports contain the full Tags JSON per cost record, enabling accurate per-resource tag parsing
+- **Forecast enrichment** — Hub data contains actuals only, so the TUI calls the Cost Management Forecast API to project full-month costs and adds them to Hub actuals
+- **Accurate tag coverage** — Hub only sees resources with cost data. The TUI queries Azure Resource Graph for the true total/untagged resource count and overrides the Hub-derived coverage percentage
+
+Hub data is loaded once at startup and reused across all scans that need it.
+
+## Scripting (Non-Interactive)
+
+The scan modules can be called directly without the TUI:
+
+```powershell
+Import-Module .\FinOpsMultitool.psm1
+
+# Run a single scan
+$tags = Get-TagInventory -Subscriptions $subs -TenantId $tid
+
+# Read Hub data and convert
+$hubData = Read-FinOpsHubData -StorageAccountName 'myhub' -ResourceGroupName 'rg-hub' -Months 1
+$tagInventory = ConvertTo-TagInventoryFromHub -HubData $hubData
+$costByTag = ConvertTo-CostByTagFromHub -HubData $hubData -ExistingTags $tagInventory.TagNames
+```
+
+## MCP Server (AI Integration)
+
+The FinOps Multitool includes an MCP (Model Context Protocol) server that exposes all 20 scan modules as AI-callable tools. This lets Copilot, Claude, custom agents, and SRE automation call the same functions used by the TUI and GUI.
+
+### Setup
+
+Add to your VS Code `settings.json` or `.vscode/mcp.json`:
+
+```json
+{
+ "mcp": {
+ "servers": {
+ "finops-multitool": {
+ "command": "pwsh",
+ "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"]
+ }
+ }
+ }
+}
+```
+
+### Available Tools
+
+| Tool | Description |
+| ---- | ----------- |
+| `scan_orphaned_resources` | Find unattached disks, NICs, public IPs, NSGs |
+| `scan_idle_vms` | Find VMs with <5% CPU over 14-30 days |
+| `scan_storage_tier_advice` | Storage accounts that could use cooler tiers |
+| `scan_ahb_opportunities` | VMs/SQL not using Azure Hybrid Benefit |
+| `scan_tag_inventory` | Tag coverage %, tag names, resource counts |
+| `scan_tag_recommendations` | Inconsistent casing, missing standard tags |
+| `scan_policy_inventory` | Policy assignments with compliance status |
+| `scan_policy_recommendations` | Policy coverage gaps for cost governance |
+| `scan_cost_data` | Actual + forecasted cost per subscription |
+| `scan_resource_costs` | Top resources by cost (MTD) |
+| `scan_cost_by_tag` | Spend breakdown by tag key/value |
+| `scan_cost_trend` | Month-over-month spend comparison |
+| `scan_reservation_advice` | RI purchase recommendations |
+| `scan_commitment_utilization` | RI and Savings Plan usage rates |
+| `scan_savings_realized` | Actual savings from commitments |
+| `scan_budget_status` | Budget consumption vs thresholds |
+| `scan_anomaly_alerts` | Recent cost anomaly detections |
+| `scan_optimization_advice` | Azure Advisor cost recommendations |
+| `scan_billing_structure` | Billing account hierarchy |
+| `scan_contract_info` | Agreement type, offer, support plan |
+| `run_full_scan` | Run all modules — comprehensive assessment |
+
+### Resources
+
+| URI | Description |
+| --- | ----------- |
+| `finops://permissions` | Required RBAC roles per scan module |
+| `finops://modules` | List of all available scan modules |
+
+### Architecture
+
+```
+AI Agent (Copilot / Claude / SRE Agent)
+ │ MCP Protocol (stdio JSON-RPC)
+ ▼
+Start-McpServer.ps1
+ │ Imports FinOpsMultitool.psm1
+ ▼
+Get-CostData, Get-TagInventory, etc.
+ │ Same functions used by TUI and GUI
+ ▼
+Azure APIs (Cost Management, Resource Graph, Advisor, etc.)
+```
+
+## File Structure
+
+```
+FinOpsMultitool/
+├── README.md # This file
+├── FinOpsMultitool.psm1 # Module loader (dot-sources all scan modules)
+├── Invoke-FinOpsMultitool.ps1 # TUI entry point
+├── Start-FinOpsMultitool.ps1 # GUI entry point (WPF/XAML, Windows only)
+├── Start-McpServer.ps1 # MCP server (AI integration, stdio JSON-RPC)
+├── modules/
+│ ├── helpers/
+│ │ ├── Read-FinOpsHubData.ps1 # Hub storage reader + converters
+│ │ ├── Get-PlainAccessToken.ps1 # Token helper
+│ │ ├── Invoke-AzRestMethodWithRetry.ps1 # REST retry logic
+│ │ ├── Search-AzGraphSafe.ps1 # ARG query wrapper
+│ │ └── MgCostScope.ps1 # Management group scope state
+│ ├── Initialize-Scanner.ps1
+│ ├── Get-CostData.ps1
+│ ├── Get-ResourceCosts.ps1
+│ ├── Get-TagInventory.ps1
+│ ├── Get-CostByTag.ps1
+│ ├── Get-OrphanedResources.ps1
+│ ├── Get-IdleVMs.ps1
+│ └── ... # One file per scan module
+└── gui/ # WPF/XAML assets for GUI mode
+```
diff --git a/src/powershell/Private/FinOpsMultitool/Start-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Start-FinOpsMultitool.ps1
new file mode 100644
index 000000000..deb58726c
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/Start-FinOpsMultitool.ps1
@@ -0,0 +1,5272 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+###########################################################################
+# FINOPSMULTITOOL
+###########################################################################
+# Purpose: Launch the AZURE FINOPS MULTITOOL WPF application. Authenticates
+# to Azure, scans the tenant for cost/tag/optimization data, and
+# displays results in an interactive GUI.
+#
+# Usage: .\Start-FinOpsMultitool.ps1
+#
+# Requirements:
+# - PowerShell 5.1+ (Windows) or 7+ with WindowsCompatibility
+# - Az PowerShell modules: Az.Accounts, Az.Resources, Az.ResourceGraph,
+# Az.CostManagement, Az.Advisor, Az.Billing
+# - Azure RBAC: Reader + Cost Management Reader on target scope
+###########################################################################
+
+#Requires -Version 5.1
+
+# -- Load WPF Assemblies ------------------------------------------------
+Add-Type -AssemblyName PresentationFramework
+Add-Type -AssemblyName PresentationCore
+Add-Type -AssemblyName WindowsBase
+
+# -- Load Shared Helpers ------------------------------------------------
+# These are extracted into modules/helpers/ so they can be used both by
+# the GUI (this script) and by standalone module imports (no GUI).
+$helpersPath = Join-Path $PSScriptRoot 'modules\helpers'
+. (Join-Path $helpersPath 'Get-PlainAccessToken.ps1')
+. (Join-Path $helpersPath 'Invoke-AzRestMethodWithRetry.ps1')
+. (Join-Path $helpersPath 'Search-AzGraphSafe.ps1')
+. (Join-Path $helpersPath 'MgCostScope.ps1')
+# -- Dot-Source Modules -------------------------------------------------
+$script:ScriptRootDir = $PSScriptRoot
+$modulePath = Join-Path $PSScriptRoot 'modules'
+. (Join-Path $modulePath 'Initialize-Scanner.ps1')
+. (Join-Path $modulePath 'Get-TenantHierarchy.ps1')
+. (Join-Path $modulePath 'Get-ContractInfo.ps1')
+. (Join-Path $modulePath 'Get-CostData.ps1')
+. (Join-Path $modulePath 'Get-ResourceCosts.ps1')
+. (Join-Path $modulePath 'Get-TagInventory.ps1')
+. (Join-Path $modulePath 'Get-CostByTag.ps1')
+. (Join-Path $modulePath 'Get-AHBOpportunities.ps1')
+. (Join-Path $modulePath 'Get-ReservationAdvice.ps1')
+. (Join-Path $modulePath 'Get-OptimizationAdvice.ps1')
+. (Join-Path $modulePath 'Get-TagRecommendations.ps1')
+. (Join-Path $modulePath 'Get-CostTrend.ps1')
+. (Join-Path $modulePath 'Deploy-ResourceTag.ps1')
+. (Join-Path $modulePath 'Get-BillingStructure.ps1')
+. (Join-Path $modulePath 'Get-CommitmentUtilization.ps1')
+. (Join-Path $modulePath 'Get-OrphanedResources.ps1')
+. (Join-Path $modulePath 'Get-BudgetStatus.ps1')
+. (Join-Path $modulePath 'Get-AnomalyAlerts.ps1')
+. (Join-Path $modulePath 'Get-SavingsRealized.ps1')
+. (Join-Path $modulePath 'Get-PolicyInventory.ps1')
+. (Join-Path $modulePath 'Get-PolicyRecommendations.ps1')
+. (Join-Path $modulePath 'Deploy-PolicyAssignment.ps1')
+. (Join-Path $modulePath 'Get-StorageTierAdvice.ps1')
+. (Join-Path $modulePath 'Get-IdleVMs.ps1')
+
+# -- Load XAML ----------------------------------------------------------
+$xamlPath = Join-Path $PSScriptRoot 'gui\MainWindow.xaml'
+$xamlContent = Get-Content $xamlPath -Raw
+
+# Remove x:Name -> Name for FindName compatibility
+$xamlContent = $xamlContent -replace 'x:Name=', 'Name='
+# Remove x:Key and x:Class attributes that cause parse issues
+$xamlContent = $xamlContent -replace 'x:Class="[^"]*"', ''
+
+$reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xamlContent))
+$window = [System.Windows.Markup.XamlReader]::Load($reader)
+$script:window = $window
+
+# Set custom window icon
+$icoPath = Join-Path $PSScriptRoot 'gui\app.ico'
+if (Test-Path $icoPath) {
+ $iconUri = [System.Uri]::new($icoPath)
+ $window.Icon = [System.Windows.Media.Imaging.BitmapFrame]::Create($iconUri)
+}
+
+# -- Find Named Controls -----------------------------------------------
+$controls = @(
+ 'TenantLabel', 'VersionLabel', 'TenantButton', 'GovTenantButton', 'ScanButton', 'ExportButton',
+ 'ProgressBar', 'StatusText', 'HierarchyTree', 'DetailTabs',
+ # Overview
+ 'ContractTypeText', 'ContractDetailText', 'TotalCostText',
+ 'ForecastText', 'SubCountText', 'TotalSavingsText', 'SubCostGrid',
+ 'CostAccessWarning', 'CostAccessWarningText',
+ 'ResourceCostGrid',
+ 'ResourceCountNote',
+ # Cost Analysis
+ 'TrendChart', 'TrendNote', 'TrendSubSelector',
+ 'TagSelector', 'CostByTagGrid', 'NoTagsLabel',
+ # Tags
+ 'TagCountText', 'TagCoverageText', 'UntaggedCountText',
+ 'TagInventoryGrid', 'TagComplianceText', 'TagRecsGrid',
+ 'UntaggedNote', 'UntaggedResourcesGrid',
+ 'CustomTagButton', 'TagDeployPanel', 'TagDeployTitle',
+ 'TagNameLabel', 'TagNameInput',
+ 'TagScopeSelector', 'TagValueInput', 'TagDeployButton',
+ 'TagDeployCancelButton', 'TagDeployStatus',
+ # Overview - Budget & Scorecard
+ 'SavingsRealizedText', 'SavingsRealizedDetail',
+ 'BudgetSummaryText', 'BudgetGrid', 'ScorecardGrid',
+ # Cost Analysis - Anomalies
+ 'AnomalyNote', 'AnomalyGrid',
+ # Cost Analysis - API Alerts
+ 'AlertsSummaryNote', 'TriggeredAlertsGrid', 'ConfiguredRulesGrid',
+ # Optimization
+ 'AHBCountText', 'AHBDetailText', 'OrphanCountText', 'OrphanDetailText',
+ 'RIUtilText', 'RIUtilDetail', 'RIContractNote', 'SPContractNote',
+ 'AdvisorCountText', 'AdvisorSavingsText', 'AHBSummaryText',
+ 'AHBGrid', 'RIGrid', 'SPGrid', 'AdvisorGrid',
+ 'CommitmentGrid', 'OrphanGrid', 'OrphanSummaryText',
+ 'IdleVMGrid', 'IdleVMSummaryText',
+ 'StorageTierGrid', 'StorageTierSummaryText',
+ # Resources Tab
+ 'ResourcesPanel', 'ResourcesFinOpsPanel', 'ResourcesCostPanel',
+ 'ResourcesRatePanel', 'ResourcesGovernancePanel', 'ResourcesToolsPanel',
+ # Billing
+ 'BillingAccessNote', 'BillingAccountsGrid', 'BillingProfilesGrid',
+ 'InvoiceSectionsGrid', 'EADeptHeader', 'EADeptGrid', 'CostAllocationGrid',
+ # Budgets Tab
+ 'BudgetSubSelector', 'BudgetSubSummary', 'BudgetDetailGrid',
+ 'BudgetDeployPanel', 'BudgetDeployScopeSelector',
+ 'BudgetDeployNameInput', 'BudgetDeployAmountInput', 'BudgetDeployGrainSelector',
+ 'BudgetDeployEmailInput', 'BudgetActionGroupSelector',
+ 'BudgetThreshold1', 'BudgetThreshold1Type',
+ 'BudgetThreshold2', 'BudgetThreshold2Type',
+ 'BudgetThreshold3', 'BudgetThreshold3Type',
+ 'BudgetThreshold4', 'BudgetThreshold4Type',
+ 'BudgetDeployTagNameSelector', 'BudgetDeployTagValueInput',
+ 'BudgetDeployButton', 'BudgetDeployCancelButton', 'BudgetDeployStatus',
+ 'BudgetPolicyPanel', 'BudgetPolicyEffectSelector', 'BudgetPolicyScopeSelector',
+ 'BudgetPolicyDeployButton', 'BudgetPolicyCancelButton', 'BudgetPolicyStatus',
+ # Guidance
+ 'GuidanceScorePanel', 'ActionPlanSubtitle', 'ActionPlanPanel',
+ 'UnderstandPanel', 'QuantifyPanel', 'OptimizePanel',
+ 'PersonasPanel',
+ # Policy
+ 'PolicyCountText', 'PolicyComplianceText', 'PolicyNonCompliantText',
+ 'PolicyRecsCountText', 'PolicyInventoryGrid', 'PolicyComplianceGrid',
+ 'PolicyRecsComplianceText', 'PolicyRecsGrid',
+ 'PolicyDeployPanel', 'PolicyDeployTitle', 'PolicyScopeSelector',
+ 'PolicyEffectSelector', 'PolicyParamsPanel', 'PolicyDeployButton',
+ 'PolicyRemediateButton', 'PolicyDeployCancelButton', 'PolicyDeployStatus'
+)
+
+foreach ($name in $controls) {
+ $ctrl = $window.FindName($name)
+ if ($ctrl) { Set-Variable -Name $name -Value $ctrl -Scope Script }
+}
+
+# -- Global Scan Data --------------------------------------------------
+$script:scanData = @{
+ Auth = $null
+ Hierarchy = $null
+ Contract = $null
+ Costs = $null
+ ResourceCosts = $null
+ Tags = $null
+ CostByTag = $null
+ CostTrend = $null
+ AHB = $null
+ Reservations = $null
+ Optimization = $null
+ TagRecs = $null
+ Billing = $null
+ Commitments = $null
+ Orphans = $null
+ Budgets = $null
+ Savings = $null
+ PolicyInv = $null
+ PolicyRecs = $null
+ StorageTier = $null
+ IdleVMs = $null
+}
+
+# -- Session Action Log (tags deployed/removed, policies assigned/unassigned) --
+$script:actionLog = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+# -- Cost Access Issue Tracking ----------------------------------------
+# Set by cost query modules when they detect billing policy restrictions.
+# Checked by Populate-OverviewTab to display a warning banner.
+$script:costAccessIssue = $null
+
+###########################################################################
+# HELPER FUNCTIONS
+###########################################################################
+
+function Update-UIStatus {
+ param([string]$Message, [int]$Percent)
+ $script:StatusText.Text = $Message
+ $script:ProgressBar.Value = $Percent
+ # Force UI refresh
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [action] {}, [System.Windows.Threading.DispatcherPriority]::Background
+ )
+}
+
+# Lightweight status update for modules to call mid-loop (no progress bar change).
+# Keeps the UI responsive during long per-subscription iterations.
+function Update-ScanStatus {
+ param([string]$Message)
+ if ($script:StatusText) {
+ $script:StatusText.Text = $Message
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [action] {}, [System.Windows.Threading.DispatcherPriority]::Background
+ )
+ }
+}
+
+function Get-CurrencySymbol {
+ param([string]$Code)
+ switch ($Code) {
+ 'USD' { '$' }
+ 'EUR' { [char]0x20AC }
+ 'GBP' { [char]0x00A3 }
+ 'JPY' { [char]0x00A5 }
+ 'CAD' { 'C$' }
+ 'AUD' { 'A$' }
+ 'CHF' { 'CHF ' }
+ 'INR' { [char]0x20B9 }
+ 'BRL' { 'R$' }
+ 'KRW' { [char]0x20A9 }
+ 'MXN' { 'MX$' }
+ 'SEK' { 'kr ' }
+ 'NOK' { 'kr ' }
+ 'DKK' { 'kr ' }
+ 'ZAR' { 'R ' }
+ default { "$Code " }
+ }
+}
+
+# -- Tree View Population ----------------------------------------------
+function Add-HierarchyNode {
+ param(
+ [object]$Group,
+ [System.Windows.Controls.ItemsControl]$Parent,
+ [hashtable]$CostMap,
+ [object[]]$Subscriptions
+ )
+
+ $groupItem = [System.Windows.Controls.TreeViewItem]::new()
+ $groupItem.Header = "[MG] $($Group.DisplayName)"
+ $groupItem.IsExpanded = $true
+ $groupItem.Tag = @{ Type = 'MG'; Id = $Group.Name; Name = $Group.DisplayName }
+ $groupItem.FontWeight = 'SemiBold'
+ $Parent.Items.Add($groupItem) | Out-Null
+
+ if ($Group.Children) {
+ foreach ($child in $Group.Children) {
+ if ($child.Type -eq '/subscriptions') {
+ $subItem = [System.Windows.Controls.TreeViewItem]::new()
+ $cost = ''
+ if ($CostMap -and $CostMap.ContainsKey($child.Name)) {
+ $c = $CostMap[$child.Name]
+ $cost = " [$($c.Currency) $($c.Actual.ToString('N2'))]"
+ }
+ $subItem.Header = "[$] $($child.DisplayName)$cost"
+ $subItem.Tag = @{ Type = 'Sub'; Id = $child.Name; Name = $child.DisplayName }
+ $subItem.FontWeight = 'Normal'
+ $groupItem.Items.Add($subItem) | Out-Null
+ }
+ elseif ($child.Children -or $child.Type -match 'managementGroups') {
+ Add-HierarchyNode -Group $child -Parent $groupItem -CostMap $CostMap -Subscriptions $Subscriptions
+ }
+ }
+ }
+}
+
+# -- Tab Population Functions ------------------------------------------
+function Populate-OverviewTab {
+ $d = $script:scanData
+
+ # Cost access warning banner
+ if ($script:costAccessIssue) {
+ $agreementType = if ($d.Contract -and $d.Contract[0].AgreementType) { $d.Contract[0].AgreementType } else { '' }
+ $warningMsg = switch ($script:costAccessIssue) {
+ 'EA' { "Cost data is unavailable. This EA enrollment has 'AO View Charges' disabled. An Enterprise Administrator must enable it in the Azure portal (Cost Management + Billing > Enrollment > Policies) for cost data to appear." }
+ 'MCA' {
+ if ($agreementType -eq 'MicrosoftPartnerAgreement') {
+ "Cost data is unavailable. This subscription is managed by a CSP partner. The partner must enable Azure Cost Management access in Partner Center for cost data to appear."
+ }
+ else {
+ "Cost data is unavailable. Verify that your account has the Billing Profile Reader or Cost Management Reader role on the billing profile. For MCA subscriptions, cost access is controlled by billing RBAC, not subscription RBAC."
+ }
+ }
+ default { "Cost data is unavailable due to a billing access restriction. Contact your billing administrator." }
+ }
+ $script:CostAccessWarningText.Text = $warningMsg
+ $script:CostAccessWarning.Visibility = 'Visible'
+ }
+ else {
+ $script:CostAccessWarning.Visibility = 'Collapsed'
+ }
+
+ # Contract
+ if ($d.Contract -and $d.Contract.Count -gt 0) {
+ $primary = $d.Contract[0]
+ $script:ContractTypeText.Text = $primary.FriendlyType
+ $script:ContractDetailText.Text = $primary.AccountName
+ }
+
+ # Subscription count
+ $subCount = $d.Auth.Subscriptions.Count
+ $skippedCount = if ($d.Auth.SkippedSubs) { $d.Auth.SkippedSubs.Count } else { 0 }
+ if ($skippedCount -gt 0) {
+ $script:SubCountText.Text = "$subCount (+$skippedCount skipped)"
+ }
+ else {
+ $script:SubCountText.Text = $subCount.ToString()
+ }
+
+ # Total costs
+ $totalActual = 0; $totalForecast = 0; $currency = 'USD'
+ if ($d.Costs) {
+ foreach ($entry in $d.Costs.GetEnumerator()) {
+ $totalActual += $entry.Value.Actual
+ $totalForecast += $entry.Value.Forecast
+ $currency = $entry.Value.Currency
+ }
+ }
+ $script:TotalCostText.Text = "$(Get-CurrencySymbol $currency)$($totalActual.ToString('N2'))"
+ $script:ForecastText.Text = "$(Get-CurrencySymbol $currency)$($totalForecast.ToString('N2'))"
+
+ # Total savings
+ $totalSavings = 0
+ if ($d.Optimization) { $totalSavings += $d.Optimization.EstimatedAnnualSavings }
+ if ($d.Reservations) { $totalSavings += $d.Reservations.EstimatedAnnualSavings }
+ $script:TotalSavingsText.Text = "`$$($totalSavings.ToString('N2'))/yr"
+
+ # Savings Realized card
+ if ($d.Savings) {
+ $sym = Get-CurrencySymbol $currency
+ $script:SavingsRealizedText.Text = "$sym$($d.Savings.TotalMonthly.ToString('N2'))/mo"
+ $parts = @()
+ if ($d.Savings.RISavingsMonthly -gt 0) { $parts += "RI: $sym$($d.Savings.RISavingsMonthly.ToString('N0'))" }
+ if ($d.Savings.SPSavingsMonthly -gt 0) { $parts += "SP: $sym$($d.Savings.SPSavingsMonthly.ToString('N0'))" }
+ if ($d.Savings.AHBSavingsMonthly -gt 0) { $parts += "AHB: $sym$($d.Savings.AHBSavingsMonthly.ToString('N0'))" }
+ $script:SavingsRealizedDetail.Text = if ($parts.Count -gt 0) { $parts -join ' | ' } else { 'No existing commitment savings detected' }
+ }
+
+ # Subscription cost grid
+ $subRows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $totalSubActual = 0
+ if ($d.Costs) {
+ foreach ($entry in $d.Costs.GetEnumerator()) { $totalSubActual += $entry.Value.Actual }
+ }
+ foreach ($sub in $d.Auth.Subscriptions) {
+ $c = if ($d.Costs -and $d.Costs.ContainsKey($sub.Id)) { $d.Costs[$sub.Id] } else { @{ Actual = 0; Forecast = 0; Currency = 'USD' } }
+ $pct = if ($totalSubActual -gt 0) { [math]::Round(($c.Actual / $totalSubActual) * 100, 2) } else { 0 }
+
+ # Estimate orphan savings for this sub
+ $orphanSave = 0.0
+ if ($d.Orphans -and $d.Orphans.Orphans) {
+ $subOrphans = @($d.Orphans.Orphans | Where-Object { $_.SubscriptionId -eq $sub.Id })
+ foreach ($o in $subOrphans) {
+ $orphanSave += switch ($o.Category) {
+ 'Orphaned Disk' {
+ $diskGb = 0
+ if ($o.Detail -match '(\d+)\s*GB') { $diskGb = [int]$Matches[1] }
+ if ($o.Detail -match 'Premium') { $diskGb * 0.12 }
+ elseif ($o.Detail -match 'Standard_SSD') { $diskGb * 0.075 }
+ else { $diskGb * 0.04 }
+ }
+ 'Unattached Public IP' { 3.65 }
+ 'Unattached NIC' { 0 }
+ 'Deallocated VM' { 15 }
+ 'Empty App Service Plan' { 55 }
+ 'Old Snapshot' { 5 }
+ default { 5 }
+ }
+ }
+ }
+ $sym = Get-CurrencySymbol $c.Currency
+
+ [void]$subRows.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ 'Actual (MTD)' = $c.Actual.ToString('N2')
+ 'Forecast' = $c.Forecast.ToString('N2')
+ '% of Total' = "$pct%"
+ Currency = $c.Currency
+ })
+ }
+ $script:SubCostGrid.ItemsSource = @($subRows | Sort-Object { [double]($_.'Actual (MTD)') } -Descending)
+
+ # Resource cost grid — dynamic threshold: include resources >= 0.1% of total forecast
+ if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) {
+ $totalActualAll = ($d.ResourceCosts | Measure-Object -Property Actual -Sum).Sum
+ $sorted = @($d.ResourceCosts | Sort-Object { $_.Actual } -Descending)
+ $totalResources = $sorted.Count
+
+ # Dynamic spend threshold: 0.01% of total actual spend (minimum $0.01 to filter noise)
+ $threshold = [math]::Max(0.01, $totalActualAll * 0.0001)
+ $display = @($sorted | Where-Object { $_.Actual -ge $threshold })
+ # Safety: if threshold filters everything, show top 50
+ if ($display.Count -eq 0) { $display = @($sorted | Select-Object -First 50) }
+
+ $resRows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($r in $display) {
+ $pct = if ($totalActualAll -gt 0) { [math]::Round(($r.Actual / $totalActualAll) * 100, 2) } else { 0 }
+ [void]$resRows.Add([PSCustomObject]@{
+ 'Resource Group' = $r.ResourceGroup
+ 'Resource Type' = $r.ResourceType
+ 'Actual (MTD)' = $r.Actual.ToString('N2')
+ 'Forecast' = $r.Forecast.ToString('N2')
+ '% of Total' = "$pct%"
+ 'Currency' = $r.Currency
+ 'Resource Path' = $r.ResourcePath
+ })
+ }
+ $script:ResourceCostGrid.ItemsSource = @($resRows)
+
+ $excluded = $totalResources - $display.Count
+ if ($excluded -gt 0) {
+ $script:ResourceCountNote.Text = "$($display.Count) of $totalResources resources shown (threshold: $(Get-CurrencySymbol $currency)$($threshold.ToString('N2'))/mo MTD, $excluded below threshold)"
+ }
+ else {
+ $script:ResourceCountNote.Text = "$totalResources resources"
+ }
+ }
+
+ # Populate tree
+ $script:HierarchyTree.Items.Clear()
+ if ($d.Hierarchy -and $d.Hierarchy.RootGroup) {
+ Add-HierarchyNode -Group $d.Hierarchy.RootGroup -Parent $script:HierarchyTree `
+ -CostMap $d.Costs -Subscriptions $d.Auth.Subscriptions
+ }
+ elseif ($d.Hierarchy -and $d.Hierarchy.FlatSubs) {
+ # Add an explanatory header when the MG tree isn't available
+ $infoItem = [System.Windows.Controls.TreeViewItem]::new()
+ $infoItem.Header = "[i] Management group hierarchy unavailable"
+ $infoItem.FontStyle = 'Italic'
+ $infoItem.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#888888')
+ $infoItem.IsEnabled = $false
+
+ $reasonItem = [System.Windows.Controls.TreeViewItem]::new()
+ $reasonItem.Header = "Requires Management Group Reader or higher role at the tenant root scope."
+ $reasonItem.FontSize = 11
+ $reasonItem.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#999999')
+ $reasonItem.IsEnabled = $false
+ $infoItem.Items.Add($reasonItem) | Out-Null
+ $infoItem.IsExpanded = $true
+ $script:HierarchyTree.Items.Add($infoItem) | Out-Null
+
+ foreach ($sub in $d.Hierarchy.FlatSubs) {
+ $item = [System.Windows.Controls.TreeViewItem]::new()
+ $cost = ''
+ if ($d.Costs -and $d.Costs.ContainsKey($sub.Id)) {
+ $c = $d.Costs[$sub.Id]
+ $cost = " [$($c.Currency) $($c.Actual.ToString('N2'))]"
+ }
+ $item.Header = "[$] $($sub.Name)$cost"
+ $item.Tag = @{ Type = 'Sub'; Id = $sub.Id; Name = $sub.Name }
+ $script:HierarchyTree.Items.Add($item) | Out-Null
+ }
+ }
+}
+
+function Populate-CostTab {
+ $d = $script:scanData.CostByTag
+
+ if (-not $d -or $d.NoTagsFound) {
+ $script:NoTagsLabel.Text = "[!] No cost-allocation tags found (CostCenter, Environment, Application, etc.). Without these tags, costs cannot be broken down by business dimension. See the Tags tab for recommended tags to implement."
+ return
+ }
+
+ if ($script:TagSelector) {
+ $script:TagSelector.Items.Clear()
+ foreach ($tagName in $d.TagsQueried) {
+ $script:TagSelector.Items.Add($tagName) | Out-Null
+ }
+ if ($d.TagsQueried.Count -gt 0) {
+ $script:TagSelector.SelectedIndex = 0
+ }
+ }
+}
+
+function Populate-TagsTab {
+ $d = $script:scanData
+
+ # Tag summary
+ if ($d.Tags) {
+ $script:TagCountText.Text = if ($null -ne $d.Tags.TagCount) { $d.Tags.TagCount.ToString() } else { '0' }
+ $script:TagCoverageText.Text = if ($null -ne $d.Tags.TagCoverage) { "$($d.Tags.TagCoverage)%" } else { '0%' }
+ $script:UntaggedCountText.Text = if ($null -ne $d.Tags.UntaggedCount) { $d.Tags.UntaggedCount.ToString('N0') } else { '0' }
+
+ # Inventory grid - preserve all tag value casing variants for discovery
+ $script:TagInventoryGrid.AutoGenerateColumns = $false
+ $script:TagInventoryGrid.Columns.Clear()
+
+ # Data columns
+ foreach ($col in @('Tag Name', 'Resources', 'Unique Values', 'Values')) {
+ $dgCol = [System.Windows.Controls.DataGridTextColumn]::new()
+ $dgCol.Header = $col
+ $dgCol.Binding = [System.Windows.Data.Binding]::new($col)
+ if ($col -eq 'Values') {
+ $dgCol.Width = [System.Windows.Controls.DataGridLength]::new(1, [System.Windows.Controls.DataGridLengthUnitType]::Star)
+ $dgCol.ElementStyle = [System.Windows.Style]::new([System.Windows.Controls.TextBlock])
+ $dgCol.ElementStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::Wrap))
+ }
+ $script:TagInventoryGrid.Columns.Add($dgCol)
+ }
+
+ # Action button template column (Remove)
+ $invActionCol = [System.Windows.Controls.DataGridTemplateColumn]::new()
+ $invActionCol.Header = 'Action'
+ $invActionCol.Width = 75
+
+ $invCellFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.Button])
+ $invCellFactory.SetValue([System.Windows.Controls.Button]::ContentProperty, 'Remove')
+ $invCellFactory.SetBinding([System.Windows.Controls.Button]::TagProperty, [System.Windows.Data.Binding]::new('Tag Name'))
+ $invCellFactory.SetValue([System.Windows.Controls.Button]::FontSizeProperty, [double]10)
+ $invCellFactory.SetValue([System.Windows.Controls.Button]::PaddingProperty, [System.Windows.Thickness]::new(6, 1, 6, 1))
+ $invCellFactory.SetValue([System.Windows.Controls.Button]::MarginProperty, [System.Windows.Thickness]::new(2, 1, 2, 1))
+ $invCellFactory.SetValue([System.Windows.Controls.Button]::CursorProperty, [System.Windows.Input.Cursors]::Hand)
+ $invCellFactory.SetValue([System.Windows.Controls.Button]::BorderThicknessProperty, [System.Windows.Thickness]::new(1))
+ $invCellFactory.SetValue([System.Windows.Controls.Button]::BackgroundProperty, [System.Windows.Media.BrushConverter]::new().ConvertFromString('#FDE7E9'))
+ $invCellFactory.SetValue([System.Windows.Controls.Button]::ForegroundProperty, [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D13438'))
+ $invCellFactory.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler] {
+ param($sender, $e)
+ Show-TagRemovePanel -TagName $sender.Tag
+ })
+
+ $invCellTemplate = [System.Windows.DataTemplate]::new()
+ $invCellTemplate.VisualTree = $invCellFactory
+ $invActionCol.CellTemplate = $invCellTemplate
+ $script:TagInventoryGrid.Columns.Add($invActionCol)
+
+ $tagRows = @()
+ foreach ($entry in $(if ($d.Tags.TagNames) { $d.Tags.TagNames.GetEnumerator() } else { @() })) {
+ $allValues = @($entry.Value.Values | ForEach-Object { $_.Value })
+ $values = $allValues -join ', '
+ $tagRows += [PSCustomObject]@{
+ 'Tag Name' = $entry.Key
+ 'Resources' = $entry.Value.TotalResources
+ 'Unique Values' = $allValues.Count
+ 'Values' = $values
+ }
+ }
+ $script:TagInventoryGrid.ItemsSource = @($tagRows | Sort-Object 'Resources' -Descending)
+
+ # Untagged resources detail grid
+ if ($d.Tags.UntaggedResources -and $d.Tags.UntaggedResources.Count -gt 0) {
+ $total = $d.Tags.UntaggedCount
+ $shown = $d.Tags.UntaggedResources.Count
+ if ($shown -lt $total) {
+ $script:UntaggedNote.Text = "Showing $shown of $total untagged resources"
+ }
+ else {
+ $script:UntaggedNote.Text = "$shown untagged resource$(if($shown -ne 1){'s'})"
+ }
+ $script:UntaggedResourcesGrid.ItemsSource = @($d.Tags.UntaggedResources)
+ }
+ else {
+ $script:UntaggedNote.Text = "No untagged resources found"
+ $script:UntaggedResourcesGrid.ItemsSource = @()
+ }
+ }
+
+ # Tag recommendations with inline action buttons
+ if ($d.TagRecs) {
+ $presentCount = $d.TagRecs.Present.Count
+ $analysisCount = $d.TagRecs.Analysis.Count
+ $script:TagComplianceText.Text = "Tag compliance: $($d.TagRecs.CompliancePercent)% ($presentCount of $analysisCount recommended tags found)"
+
+ # Build the tag recs grid with programmatic columns including an Action button
+ $script:TagRecsGrid.AutoGenerateColumns = $false
+ $script:TagRecsGrid.Columns.Clear()
+
+ # Data columns
+ foreach ($col in @('Tag', 'Status', 'Location', 'Priority', 'Pillar', 'Purpose')) {
+ $dgCol = [System.Windows.Controls.DataGridTextColumn]::new()
+ $dgCol.Header = $col
+ $dgCol.Binding = [System.Windows.Data.Binding]::new($col)
+ if ($col -in @('Location', 'Purpose')) {
+ $dgCol.Width = [System.Windows.Controls.DataGridLength]::new(1, [System.Windows.Controls.DataGridLengthUnitType]::Star)
+ $dgCol.ElementStyle = [System.Windows.Style]::new([System.Windows.Controls.TextBlock])
+ $dgCol.ElementStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::Wrap))
+ }
+ $script:TagRecsGrid.Columns.Add($dgCol)
+ }
+
+ # Action button template column
+ $actionCol = [System.Windows.Controls.DataGridTemplateColumn]::new()
+ $actionCol.Header = 'Action'
+ $actionCol.Width = 75
+
+ $cellFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.Button])
+ $cellFactory.SetBinding([System.Windows.Controls.Button]::ContentProperty, [System.Windows.Data.Binding]::new('ActionLabel'))
+ $cellFactory.SetBinding([System.Windows.Controls.Button]::BackgroundProperty, [System.Windows.Data.Binding]::new('ActionBg'))
+ $cellFactory.SetBinding([System.Windows.Controls.Button]::ForegroundProperty, [System.Windows.Data.Binding]::new('ActionFg'))
+ $cellFactory.SetBinding([System.Windows.Controls.Button]::TagProperty, [System.Windows.Data.Binding]::new('ActionTagName'))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::FontSizeProperty, [double]10)
+ $cellFactory.SetValue([System.Windows.Controls.Button]::PaddingProperty, [System.Windows.Thickness]::new(6, 1, 6, 1))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::MarginProperty, [System.Windows.Thickness]::new(2, 1, 2, 1))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::CursorProperty, [System.Windows.Input.Cursors]::Hand)
+ $cellFactory.SetValue([System.Windows.Controls.Button]::BorderThicknessProperty, [System.Windows.Thickness]::new(1))
+ $cellFactory.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler] {
+ param($sender, $e)
+ $tagName = $sender.Tag
+ $status = $sender.Content
+ if ($status -eq 'Add') {
+ Show-TagDeployPanel -TagName $tagName
+ }
+ elseif ($status -eq 'Remove') {
+ Show-TagRemovePanel -TagName $tagName
+ }
+ })
+
+ $cellTemplate = [System.Windows.DataTemplate]::new()
+ $cellTemplate.VisualTree = $cellFactory
+ $actionCol.CellTemplate = $cellTemplate
+ $script:TagRecsGrid.Columns.Add($actionCol)
+
+ # Populate rows with action metadata
+ $brushConv = [System.Windows.Media.BrushConverter]::new()
+ $recRows = $d.TagRecs.Analysis | ForEach-Object {
+ $isMissing = $_.Status -eq 'Missing'
+ # For Remove: use the actual tag name found in Azure (handles variations + correct case)
+ # For Add: use the recommended tag name
+ $actionTag = if ($isMissing) { $_.TagName } elseif ($_.ActualTagName) { $_.ActualTagName } else { $_.TagName }
+ [PSCustomObject]@{
+ 'Tag' = $_.TagName
+ 'TagName' = $_.TagName
+ 'ActionTagName' = $actionTag
+ 'Status' = $_.Status
+ 'Location' = $_.Location
+ 'Priority' = $_.Priority
+ 'Pillar' = $_.Pillar
+ 'Purpose' = $_.Purpose
+ 'ActionLabel' = if ($isMissing) { 'Add' } else { 'Remove' }
+ 'ActionBg' = if ($isMissing) { $brushConv.ConvertFromString('#DFF6DD') } else { $brushConv.ConvertFromString('#FDE7E9') }
+ 'ActionFg' = if ($isMissing) { $brushConv.ConvertFromString('#107C10') } else { $brushConv.ConvertFromString('#D13438') }
+ }
+ }
+ $script:TagRecsGrid.ItemsSource = @($recRows)
+ }
+}
+
+#-----------------------------------------------------------------------
+# SHARED RESOURCE COST LOOKUP (used by Optimization + Orphan sections)
+#-----------------------------------------------------------------------
+$script:resCostMap = @{}
+$script:resCostMapBuilt = $false
+
+function Build-ResourceCostMap {
+ $d = $script:scanData
+ $script:resCostMap = @{}
+ if ($d.ResourceCosts) {
+ foreach ($rc in $d.ResourceCosts) {
+ if ($rc.ResourcePath) {
+ $script:resCostMap[$rc.ResourcePath.ToLower()] = $rc
+ }
+ if ($rc.ResourcePath -match '/([^/]+)$') {
+ $nameKey = $Matches[1].ToLower()
+ if (-not $script:resCostMap.ContainsKey($nameKey)) { $script:resCostMap[$nameKey] = $rc }
+ }
+ }
+ }
+ $script:resCostMapBuilt = $true
+}
+
+function Find-ResourceCost {
+ param($Name, $SubscriptionId, $ResourceGroup, $ResourceType)
+ if (-not $script:resCostMapBuilt) { Build-ResourceCostMap }
+ $rc = $null
+ if ($SubscriptionId -and $ResourceGroup -and $ResourceType -and $Name) {
+ $armId = "/subscriptions/$SubscriptionId/resourcegroups/$ResourceGroup/providers/$ResourceType/$Name".ToLower()
+ $rc = $script:resCostMap[$armId]
+ }
+ if (-not $rc -and $Name) {
+ $rc = $script:resCostMap[$Name.ToLower()]
+ }
+ return $rc
+}
+
+function Populate-OptimizationTab {
+ $d = $script:scanData
+
+ # Ensure shared resource cost map is built
+ if (-not $script:resCostMapBuilt) { Build-ResourceCostMap }
+
+ # Currency helper
+ $currency = if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) {
+ Get-CurrencySymbol -Code $d.ResourceCosts[0].Currency
+ }
+ else { '$' }
+
+ # AHB
+ if ($d.AHB) {
+ $script:AHBCountText.Text = "$($d.AHB.TotalOpportunities) resources"
+ $script:AHBDetailText.Text = "$($d.AHB.WindowsVMs.Count) VMs, $($d.AHB.SQLVMs.Count) SQL VMs, $($d.AHB.SQLDatabases.Count) SQL DBs"
+ $script:AHBSummaryText.Text = $d.AHB.Summary
+
+ $ahbRows = @()
+ foreach ($vm in $d.AHB.WindowsVMs) {
+ $rc = Find-ResourceCost -Name $vm.name -SubscriptionId $vm.subscriptionId -ResourceGroup $vm.resourceGroup -ResourceType 'microsoft.compute/virtualmachines'
+ $actual = if ($rc) { $rc.Actual } else { $null }
+ $forecast = if ($rc) { $rc.Forecast } else { $null }
+ # AHB saves ~40% on Windows VM licensing component
+ $ahbActual = if ($actual) { [math]::Round($actual * 0.6, 2) } else { $null }
+ $ahbForecast = if ($forecast) { [math]::Round($forecast * 0.6, 2) } else { $null }
+ $ahbRows += [PSCustomObject]@{
+ Type = 'Windows VM'
+ Name = $vm.name
+ ResourceGroup = $vm.resourceGroup
+ Size = $vm.vmSize
+ CurrentLicense = $vm.currentLicense
+ Location = $vm.location
+ 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' }
+ 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' }
+ 'With AHB (MTD)' = if ($ahbActual) { "$currency$($ahbActual.ToString('N2'))" } else { '-' }
+ 'With AHB (Mo.)' = if ($ahbForecast) { "$currency$($ahbForecast.ToString('N2'))" } else { '-' }
+ }
+ }
+ foreach ($sql in $d.AHB.SQLVMs) {
+ $rc = Find-ResourceCost -Name $sql.name -SubscriptionId $sql.subscriptionId -ResourceGroup $sql.resourceGroup -ResourceType 'microsoft.sqlvirtualmachine/sqlvirtualmachines'
+ $actual = if ($rc) { $rc.Actual } else { $null }
+ $forecast = if ($rc) { $rc.Forecast } else { $null }
+ $ahbActual = if ($actual) { [math]::Round($actual * 0.45, 2) } else { $null }
+ $ahbForecast = if ($forecast) { [math]::Round($forecast * 0.45, 2) } else { $null }
+ $ahbRows += [PSCustomObject]@{
+ Type = 'SQL VM'
+ Name = $sql.name
+ ResourceGroup = $sql.resourceGroup
+ Size = $sql.sqlEdition
+ CurrentLicense = $sql.currentLicense
+ Location = $sql.location
+ 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' }
+ 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' }
+ 'With AHB (MTD)' = if ($ahbActual) { "$currency$($ahbActual.ToString('N2'))" } else { '-' }
+ 'With AHB (Mo.)' = if ($ahbForecast) { "$currency$($ahbForecast.ToString('N2'))" } else { '-' }
+ }
+ }
+ foreach ($db in $d.AHB.SQLDatabases) {
+ $rc = Find-ResourceCost -Name $db.name -SubscriptionId $db.subscriptionId -ResourceGroup $db.resourceGroup -ResourceType 'microsoft.sql/servers/databases'
+ $actual = if ($rc) { $rc.Actual } else { $null }
+ $forecast = if ($rc) { $rc.Forecast } else { $null }
+ # AHB saves ~55% on SQL DB licensing component
+ $ahbActual = if ($actual) { [math]::Round($actual * 0.45, 2) } else { $null }
+ $ahbForecast = if ($forecast) { [math]::Round($forecast * 0.45, 2) } else { $null }
+ $ahbRows += [PSCustomObject]@{
+ Type = 'SQL Database'
+ Name = $db.name
+ ResourceGroup = $db.resourceGroup
+ Size = $db.sku
+ CurrentLicense = $db.currentLicense
+ Location = $db.location
+ 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' }
+ 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' }
+ 'With AHB (MTD)' = if ($ahbActual) { "$currency$($ahbActual.ToString('N2'))" } else { '-' }
+ 'With AHB (Mo.)' = if ($ahbForecast) { "$currency$($ahbForecast.ToString('N2'))" } else { '-' }
+ }
+ }
+ if ($ahbRows.Count -eq 0) {
+ $script:AHBGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No AHB-eligible resources found. All resources are using Azure Hybrid Benefit or are not eligible.' })
+ }
+ else {
+ $script:AHBGrid.ItemsSource = @($ahbRows)
+ }
+ }
+ else {
+ $script:AHBGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No AHB-eligible resources found.' })
+ }
+
+ # Reservations - split RI vs SP
+ if ($d.Reservations) {
+ # Classify advisor recs as RI or SP
+ $riRecs = @()
+ $spRecs = @()
+ foreach ($rec in $d.Reservations.AdvisorRecommendations) {
+ if ($rec.Problem -match 'savings plan' -or $rec.Solution -match 'savings plan') {
+ $spRecs += $rec
+ }
+ else {
+ $riRecs += $rec
+ }
+ }
+
+ # Contract-aware note
+ $contractType = ''
+ if ($d.Contract -and $d.Contract.Count -gt 0) {
+ $contractType = $d.Contract[0].AgreementType
+ }
+ $contractNote = switch -Regex ($contractType) {
+ 'EnterpriseAgreement' { 'EA customers: RI/SP pricing reflects your negotiated EA rates. Savings shown are vs. your EA pay-as-you-go rate.' }
+ 'MicrosoftCustomerAgreement' { 'MCA customers: RI/SP savings are calculated against your MCA list prices. Actual savings may vary based on negotiated discounts.' }
+ 'MicrosoftOnlineServicesProgram' { 'PAYGO customers: Savings shown are vs. retail pay-as-you-go rates. Consider an EA or MCA for even deeper discounts on top of RI/SP.' }
+ default { 'Savings are estimated against your current pricing model.' }
+ }
+ if ($script:RIContractNote) { $script:RIContractNote.Text = $contractNote }
+ if ($script:SPContractNote) { $script:SPContractNote.Text = $contractNote }
+
+ # RI grid - Advisor RI recs + Reservation API recs
+ $riRows = @()
+ foreach ($rec in $riRecs) {
+ $rc = Find-ResourceCost -Name $rec.ResourceName -SubscriptionId $rec.SubscriptionId -ResourceGroup $null -ResourceType $rec.ResourceType
+ $actual = if ($rc) { $rc.Actual } else { $null }
+ $forecast = if ($rc) { $rc.Forecast } else { $null }
+ $monthlySavings = if ($rec.AnnualSavings) { [math]::Round($rec.AnnualSavings / 12, 2) } else { $null }
+ $riRows += [PSCustomObject]@{
+ Subscription = $rec.Subscription
+ Resource = $rec.ResourceName
+ 'Resource Type' = $rec.ResourceType
+ Impact = $rec.Impact
+ Problem = $rec.Problem
+ Solution = $rec.Solution
+ Term = if ($rec.Term) { $rec.Term } else { '-' }
+ 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' }
+ 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' }
+ 'With RI (Mo.)' = if ($monthlySavings -and $forecast) { "$currency$([math]::Round($forecast - $monthlySavings, 2).ToString('N2'))" } else { '-' }
+ 'Annual Savings' = if ($rec.AnnualSavings) { "$currency$($rec.AnnualSavings.ToString('N2'))" } else { '-' }
+ }
+ }
+ foreach ($rr in $d.Reservations.ReservationRecommendations) {
+ $riRows += [PSCustomObject]@{
+ Subscription = '-'
+ Resource = if ($rr.SKU) { $rr.SKU } else { $rr.ResourceType }
+ 'Resource Type' = $rr.ResourceType
+ Impact = 'High'
+ Problem = "$($rr.RecommendedQty) x $($rr.ResourceType) at PAYG rates"
+ Solution = "Purchase $($rr.RecommendedQty) reserved instance(s) ($($rr.Term))"
+ Term = if ($rr.Term) { $rr.Term } else { '-' }
+ 'Actual (MTD)' = '-'
+ 'Forecast' = if ($rr.CostWithoutRI) { "$currency$($rr.CostWithoutRI.ToString('N2'))" } else { '-' }
+ 'With RI (Mo.)' = if ($rr.CostWithRI) { "$currency$($rr.CostWithRI.ToString('N2'))" } else { '-' }
+ 'Annual Savings' = if ($rr.NetSavings) { "$currency$($rr.NetSavings.ToString('N2'))" } else { '-' }
+ }
+ }
+ if ($riRows.Count -eq 0) {
+ $script:RIGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Reserved Instance recommendations at this time.' })
+ }
+ else {
+ $script:RIGrid.ItemsSource = @($riRows)
+ }
+
+ # SP grid
+ $spRows = @()
+ foreach ($rec in $spRecs) {
+ $rc = Find-ResourceCost -Name $rec.ResourceName -SubscriptionId $rec.SubscriptionId -ResourceGroup $null -ResourceType $rec.ResourceType
+ $actual = if ($rc) { $rc.Actual } else { $null }
+ $forecast = if ($rc) { $rc.Forecast } else { $null }
+ $monthlySavings = if ($rec.AnnualSavings) { [math]::Round($rec.AnnualSavings / 12, 2) } else { $null }
+ $spRows += [PSCustomObject]@{
+ Subscription = $rec.Subscription
+ Resource = $rec.ResourceName
+ 'Resource Type' = $rec.ResourceType
+ Impact = $rec.Impact
+ Problem = $rec.Problem
+ Solution = $rec.Solution
+ Term = if ($rec.Term) { $rec.Term } else { '-' }
+ 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' }
+ 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' }
+ 'With SP (Mo.)' = if ($monthlySavings -and $forecast) { "$currency$([math]::Round($forecast - $monthlySavings, 2).ToString('N2'))" } else { '-' }
+ 'Annual Savings' = if ($rec.AnnualSavings) { "$currency$($rec.AnnualSavings.ToString('N2'))" } else { '-' }
+ }
+ }
+ if ($spRows.Count -eq 0) {
+ $script:SPGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Savings Plan recommendations at this time.' })
+ }
+ else {
+ $script:SPGrid.ItemsSource = @($spRows)
+ }
+ }
+ else {
+ $script:RIGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Reserved Instance recommendations at this time.' })
+ $script:SPGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Savings Plan recommendations at this time.' })
+ }
+
+ # Advisor
+ if ($d.Optimization -and $d.Optimization.TotalCount -gt 0) {
+ $script:AdvisorCountText.Text = $d.Optimization.TotalCount.ToString()
+ $script:AdvisorSavingsText.Text = "Est. $currency$($d.Optimization.EstimatedAnnualSavings.ToString('N2'))/yr"
+
+ $advRows = @()
+ foreach ($rec in $d.Optimization.Recommendations) {
+ $rc = Find-ResourceCost -Name $rec.ResourceName -SubscriptionId $rec.SubscriptionId -ResourceGroup $null -ResourceType $rec.ResourceType
+ $actual = if ($rc) { $rc.Actual } else { $null }
+ $forecast = if ($rc) { $rc.Forecast } else { $null }
+ $monthlySavings = if ($rec.AnnualSavings) { [math]::Round($rec.AnnualSavings / 12, 2) } else { $null }
+ $advRows += [PSCustomObject]@{
+ Category = $rec.Category
+ Subscription = $rec.Subscription
+ Impact = $rec.Impact
+ Resource = $rec.ResourceName
+ Problem = $rec.Problem
+ Solution = $rec.Solution
+ 'Actual (MTD)' = if ($actual) { "$currency$($actual.ToString('N2'))" } else { '-' }
+ 'Forecast' = if ($forecast) { "$currency$($forecast.ToString('N2'))" } else { '-' }
+ 'With Fix (Mo.)' = if ($monthlySavings -and $forecast) { "$currency$([math]::Round($forecast - $monthlySavings, 2).ToString('N2'))" } else { '-' }
+ 'Annual Savings' = if ($rec.AnnualSavings) { "$currency$($rec.AnnualSavings.ToString('N2'))" } else { '-' }
+ }
+ }
+ $script:AdvisorGrid.ItemsSource = @($advRows)
+ }
+ else {
+ $script:AdvisorCountText.Text = '0'
+ $script:AdvisorSavingsText.Text = "$currency" + "0.00/yr"
+ $script:AdvisorGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No Advisor cost optimization recommendations at this time. This is normal for well-optimized or small environments.' })
+ }
+}
+
+function Populate-GuidanceTab {
+ $d = $script:scanData
+
+ # Currency helper
+ $currency = if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) {
+ Get-CurrencySymbol -Code $d.ResourceCosts[0].Currency
+ }
+ else { '$' }
+
+ # =====================================================================
+ # HELPER: Add a rich text line to a StackPanel
+ # =====================================================================
+ function Add-GuidanceLine {
+ param(
+ [System.Windows.Controls.StackPanel]$Panel,
+ [string]$Icon, # Emoji-style prefix e.g. [!] or checkmark
+ [string]$Bold, # Bold portion
+ [string]$Normal, # Normal text after bold
+ [string]$Color = '#444',
+ [double]$FontSize = 12.5,
+ [double]$BottomMargin = 6
+ )
+ $tb = [System.Windows.Controls.TextBlock]::new()
+ $tb.TextWrapping = 'Wrap'
+ $tb.FontSize = $FontSize
+ $tb.Margin = [System.Windows.Thickness]::new(0, 0, 0, $BottomMargin)
+
+ if ($Icon) {
+ $iconRun = [System.Windows.Documents.Run]::new("$Icon ")
+ $iconRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($Color)
+ $iconRun.FontWeight = 'Bold'
+ $tb.Inlines.Add($iconRun) | Out-Null
+ }
+ if ($Bold) {
+ $boldRun = [System.Windows.Documents.Run]::new($Bold)
+ $boldRun.FontWeight = 'Bold'
+ $boldRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#222')
+ $tb.Inlines.Add($boldRun) | Out-Null
+ }
+ if ($Normal) {
+ $sep = if ($Bold) { ' ' } else { '' }
+ $normRun = [System.Windows.Documents.Run]::new("$sep$Normal")
+ $normRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#444')
+ $tb.Inlines.Add($normRun) | Out-Null
+ }
+ $Panel.Children.Add($tb) | Out-Null
+ }
+
+ # =====================================================================
+ # FINOPS MATURITY SCORE (0-100)
+ # Based on FinOps Foundation Maturity Model + Microsoft CAF
+ # Categories: Visibility (25), Allocation (20), Budgeting (15),
+ # Optimization (20), Governance (20)
+ # =====================================================================
+ $score = 0
+ $maxScore = 100
+ $breakdown = @{}
+
+ # --- Visibility (25 pts) -------------------------------------------
+ $visScore = 0
+ # Tag coverage: 0-10 pts
+ if ($d.Tags) {
+ $visScore += [math]::Min([math]::Floor($d.Tags.TagCoverage / 10), 10)
+ }
+ # Cost data available: 5 pts
+ if ($d.Costs -and $d.Costs.Count -gt 0) { $visScore += 5 }
+ # Cost trend available: 5 pts
+ if ($d.CostTrend -and $d.CostTrend.HasData) { $visScore += 5 }
+ # Resource-level cost visibility: 5 pts
+ if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) { $visScore += 5 }
+ $breakdown['Visibility'] = [math]::Min($visScore, 25)
+ $score += $breakdown['Visibility']
+
+ # --- Allocation (20 pts) -------------------------------------------
+ # Weighted per-tag scoring: CostCenter/BusinessUnit matter most for chargeback
+ $allocScore = 0
+ # Weighted tag presence: 0-12 pts
+ if ($d.Tags -and $d.Tags.TagNames) {
+ $lcKeys = $d.Tags.TagNames.Keys | ForEach-Object { $_.ToLower() }
+ $tagWeights = @{
+ 'CostCenter' = @{ Weight = 3; Alts = @('costcenter', 'cost-center', 'cost_center', 'cc') }
+ 'BusinessUnit' = @{ Weight = 3; Alts = @('businessunit', 'bu', 'business-unit', 'department', 'dept') }
+ 'ApplicationName' = @{ Weight = 2; Alts = @('applicationname', 'application', 'app', 'appname') }
+ 'WorkloadName' = @{ Weight = 1; Alts = @('workloadname', 'workload', 'workload-name') }
+ 'OpsTeam' = @{ Weight = 1; Alts = @('opsteam', 'ops-team', 'ops_team', 'owner', 'technicalowner') }
+ 'Criticality' = @{ Weight = 1; Alts = @('criticality', 'sla', 'tier') }
+ 'DataClassification' = @{ Weight = 1; Alts = @('dataclassification', 'data-classification', 'classification') }
+ }
+ foreach ($tag in $tagWeights.Keys) {
+ $allNames = @($tag.ToLower()) + $tagWeights[$tag].Alts
+ if ($lcKeys | Where-Object { $_ -in $allNames }) {
+ $allocScore += $tagWeights[$tag].Weight
+ }
+ }
+ }
+ # Cost-by-tag data available: 4 pts
+ if ($d.CostByTag -and -not $d.CostByTag.NoTagsFound -and $d.CostByTag.CostByTag.Count -gt 0) { $allocScore += 4 }
+ # Cost allocation rules configured: 4 pts
+ if ($d.Billing -and $d.Billing.CostAllocationRules -and $d.Billing.CostAllocationRules.Count -gt 0) { $allocScore += 4 }
+ $breakdown['Allocation'] = [math]::Min($allocScore, 20)
+ $score += $breakdown['Allocation']
+
+ # --- Budgeting & Forecasting (15 pts) ------------------------------
+ $budgetScore = 0
+ # Has budgets: 5 pts
+ if ($d.Budgets -and $d.Budgets.HasData) { $budgetScore += 5 }
+ # Budget coverage: 0-5 pts
+ if ($d.Budgets) {
+ $budgetScore += [math]::Min([math]::Floor($d.Budgets.BudgetCoverage / 20), 5)
+ }
+ # No budgets over 100%: 5 pts (or partial credit)
+ if ($d.Budgets -and $d.Budgets.HasData) {
+ if ($d.Budgets.OverBudgetCount -eq 0) { $budgetScore += 5 }
+ elseif ($d.Budgets.AtRiskCount -eq 0) { $budgetScore += 3 }
+ }
+ $breakdown['Budgeting'] = [math]::Min($budgetScore, 15)
+ $score += $breakdown['Budgeting']
+
+ # --- Optimization (20 pts) -----------------------------------------
+ $optScore = 0
+ # Commitment utilization > 80%: 5 pts
+ if ($d.Commitments -and $d.Commitments.HasData) {
+ if ($d.Commitments.RIAvgUtilization -ge 80) { $optScore += 5 }
+ elseif ($d.Commitments.RIAvgUtilization -ge 60) { $optScore += 3 }
+ }
+ else {
+ # No commitments = no waste, partial credit
+ $optScore += 2
+ }
+ # Savings realized from commitments: 5 pts
+ if ($d.Savings -and $d.Savings.TotalMonthly -gt 0) { $optScore += 5 }
+ # Low Advisor recommendations (fewer = better optimized): 0-5 pts
+ if ($d.Optimization) {
+ if ($d.Optimization.TotalCount -eq 0) { $optScore += 5 }
+ elseif ($d.Optimization.TotalCount -le 3) { $optScore += 3 }
+ elseif ($d.Optimization.TotalCount -le 10) { $optScore += 1 }
+ }
+ else { $optScore += 2 }
+ # Few orphaned resources: 5 pts
+ if ($d.Orphans) {
+ $orphanTotal = if ($d.Orphans.TotalCount) { $d.Orphans.TotalCount } else { 0 }
+ if ($orphanTotal -eq 0) { $optScore += 5 }
+ elseif ($orphanTotal -le 3) { $optScore += 3 }
+ elseif ($orphanTotal -le 10) { $optScore += 1 }
+ }
+ else { $optScore += 2 }
+ $breakdown['Optimization'] = [math]::Min($optScore, 20)
+ $score += $breakdown['Optimization']
+
+ # --- Governance (20 pts) -------------------------------------------
+ $govScore = 0
+ # Has Azure policies: 5 pts
+ if ($d.PolicyInv -and $d.PolicyInv.AssignmentCount -gt 0) { $govScore += 5 }
+ # FinOps policies coverage: 0-5 pts
+ if ($d.PolicyRecs) {
+ $policyPct = if ($d.PolicyRecs.Analysis.Count -gt 0) {
+ [math]::Round(($d.PolicyRecs.Assigned.Count / $d.PolicyRecs.Analysis.Count) * 100, 0)
+ }
+ else { 0 }
+ $govScore += [math]::Min([math]::Floor($policyPct / 20), 5)
+ }
+ # Policy compliance > 80%: 5 pts
+ if ($d.PolicyInv -and $d.PolicyInv.CompliancePct -ge 80) { $govScore += 5 }
+ elseif ($d.PolicyInv -and $d.PolicyInv.CompliancePct -ge 50) { $govScore += 3 }
+ # Has management group hierarchy: 5 pts
+ if ($d.Hierarchy -and $d.Hierarchy.RootGroup) { $govScore += 5 }
+ elseif ($d.Hierarchy -and $d.Hierarchy.FlatSubs) { $govScore += 2 }
+ $breakdown['Governance'] = [math]::Min($govScore, 20)
+ $score += $breakdown['Governance']
+
+ $score = [math]::Min($score, $maxScore)
+
+ # Grade label
+ $grade = switch ($true) {
+ ($score -ge 85) { 'Excellent'; break }
+ ($score -ge 70) { 'Good'; break }
+ ($score -ge 50) { 'Developing'; break }
+ ($score -ge 30) { 'Foundational'; break }
+ default { 'Getting Started' }
+ }
+
+ $gradeColor = switch ($true) {
+ ($score -ge 85) { '#107C10'; break }
+ ($score -ge 70) { '#0078D4'; break }
+ ($score -ge 50) { '#8764B8'; break }
+ ($score -ge 30) { '#FF8C00'; break }
+ default { '#D13438' }
+ }
+
+ # Store computed score on scan data so Export-ScanReport can reuse it
+ $d | Add-Member -NotePropertyName 'MaturityScore' -NotePropertyValue $score -Force
+ $d | Add-Member -NotePropertyName 'MaturityBreakdown' -NotePropertyValue $breakdown -Force
+ $d | Add-Member -NotePropertyName 'MaturityGrade' -NotePropertyValue $grade -Force
+ $d | Add-Member -NotePropertyName 'MaturityGradeColor' -NotePropertyValue $gradeColor -Force
+
+ # =====================================================================
+ # RENDER SCORE CARD
+ # =====================================================================
+ $script:GuidanceScorePanel.Children.Clear()
+
+ # Score card container
+ $scoreCard = [System.Windows.Controls.Border]::new()
+ $scoreCard.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#F8F9FA')
+ $scoreCard.CornerRadius = [System.Windows.CornerRadius]::new(8)
+ $scoreCard.Padding = [System.Windows.Thickness]::new(24)
+ $scoreCard.Margin = [System.Windows.Thickness]::new(0, 10, 0, 10)
+ $scoreCard.BorderBrush = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#E0E0E0')
+ $scoreCard.BorderThickness = [System.Windows.Thickness]::new(1)
+
+ $scoreStack = [System.Windows.Controls.StackPanel]::new()
+
+ # Title
+ $titleTb = [System.Windows.Controls.TextBlock]::new()
+ $titleTb.FontSize = 18
+ $titleTb.FontWeight = 'SemiBold'
+ $titleTb.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#333')
+ $titleTb.Margin = [System.Windows.Thickness]::new(0, 0, 0, 12)
+ $titleTb.Inlines.Add([System.Windows.Documents.Run]::new('FinOps Maturity Score: ')) | Out-Null
+ $scoreRun = [System.Windows.Documents.Run]::new("$score / $maxScore")
+ $scoreRun.FontSize = 24
+ $scoreRun.FontWeight = 'Bold'
+ $scoreRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($gradeColor)
+ $titleTb.Inlines.Add($scoreRun) | Out-Null
+ $gradeRun = [System.Windows.Documents.Run]::new(" ($grade)")
+ $gradeRun.FontSize = 16
+ $gradeRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($gradeColor)
+ $titleTb.Inlines.Add($gradeRun) | Out-Null
+ $scoreStack.Children.Add($titleTb) | Out-Null
+
+ # Methodology note
+ $methodTb = [System.Windows.Controls.TextBlock]::new()
+ $methodTb.Text = 'Score based on FinOps Foundation Maturity Model and Microsoft Cloud Adoption Framework. Categories: Visibility (25), Allocation (20), Budgeting (15), Optimization (20), Governance (20).'
+ $methodTb.TextWrapping = 'Wrap'
+ $methodTb.FontSize = 11
+ $methodTb.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#888')
+ $methodTb.Margin = [System.Windows.Thickness]::new(0, 0, 0, 12)
+ $scoreStack.Children.Add($methodTb) | Out-Null
+
+ # Category breakdown in a horizontal WrapPanel
+ $catPanel = [System.Windows.Controls.WrapPanel]::new()
+ $catColors = @{
+ 'Visibility' = '#0078D4'
+ 'Allocation' = '#005A9E'
+ 'Budgeting' = '#8764B8'
+ 'Optimization' = '#107C10'
+ 'Governance' = '#D83B01'
+ }
+ $catMax = @{ 'Visibility' = 25; 'Allocation' = 20; 'Budgeting' = 15; 'Optimization' = 20; 'Governance' = 20 }
+ foreach ($cat in @('Visibility', 'Allocation', 'Budgeting', 'Optimization', 'Governance')) {
+ $catBorder = [System.Windows.Controls.Border]::new()
+ $catBorder.Background = [System.Windows.Media.Brushes]::White
+ $catBorder.CornerRadius = [System.Windows.CornerRadius]::new(4)
+ $catBorder.Padding = [System.Windows.Thickness]::new(14, 8, 14, 8)
+ $catBorder.Margin = [System.Windows.Thickness]::new(0, 0, 10, 6)
+ $catBorder.BorderBrush = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#DDD')
+ $catBorder.BorderThickness = [System.Windows.Thickness]::new(1)
+
+ $catTb = [System.Windows.Controls.TextBlock]::new()
+ $catTb.FontSize = 12
+ $nameRun = [System.Windows.Documents.Run]::new("$cat ")
+ $nameRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#666')
+ $catTb.Inlines.Add($nameRun) | Out-Null
+
+ $valRun = [System.Windows.Documents.Run]::new("$($breakdown[$cat]) / $($catMax[$cat])")
+ $valRun.FontWeight = 'Bold'
+ $valRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($catColors[$cat])
+ $catTb.Inlines.Add($valRun) | Out-Null
+
+ $catBorder.Child = $catTb
+ $catPanel.Children.Add($catBorder) | Out-Null
+ }
+ $scoreStack.Children.Add($catPanel) | Out-Null
+
+ $scoreCard.Child = $scoreStack
+ $script:GuidanceScorePanel.Children.Add($scoreCard) | Out-Null
+
+ # =====================================================================
+ # PRIORITIZED ACTION PLAN
+ # Build a list of actions sorted by impact, with priority numbering
+ # =====================================================================
+ $script:ActionPlanPanel.Children.Clear()
+ $actions = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # --- Critical: Tag coverage ---
+ if ($d.Tags -and $d.Tags.TagCoverage -lt 50) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 1; Impact = 'Critical'; Category = 'Allocation'
+ Title = "Increase tag coverage from $($d.Tags.TagCoverage)% to 80%+"
+ Detail = 'Untagged resources cannot be allocated to business units. Use Azure Policy to enforce tagging at resource creation. Start with CostCenter, Environment, and Application tags.'
+ })
+ }
+ elseif ($d.Tags -and $d.Tags.TagCoverage -lt 80) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 2; Impact = 'High'; Category = 'Allocation'
+ Title = "Improve tag coverage from $($d.Tags.TagCoverage)% to 80%+"
+ Detail = 'Good progress on tagging. Focus on untagged resources using Azure Policy tag inheritance and the Deploy Missing Tags feature on the Tags tab.'
+ })
+ }
+
+ # --- Critical: No budgets ---
+ if (-not $d.Budgets -or -not $d.Budgets.HasData) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 1; Impact = 'Critical'; Category = 'Budgeting'
+ Title = 'Set up Azure Budgets with alert thresholds'
+ Detail = 'No budgets detected. Create budgets at the subscription level with 50%, 75%, 90%, and 100% alert thresholds. Use action groups to notify finance and engineering teams.'
+ })
+ }
+ elseif ($d.Budgets.BudgetCoverage -lt 50) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 2; Impact = 'High'; Category = 'Budgeting'
+ Title = "Expand budget coverage from $($d.Budgets.BudgetCoverage)% to 100%"
+ Detail = "Only $($d.Budgets.SubsWithBudget) of $($d.Budgets.SubsWithBudget + $d.Budgets.SubsWithoutBudget) subscriptions have budgets. Every production subscription should have an Azure Budget."
+ })
+ }
+
+ # --- High: Over-budget subscriptions ---
+ if ($d.Budgets -and $d.Budgets.OverBudgetCount -gt 0) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 1; Impact = 'Critical'; Category = 'Budgeting'
+ Title = "$($d.Budgets.OverBudgetCount) subscription(s) are over budget"
+ Detail = 'Investigate the over-budget subscriptions on the Overview tab. Check for unexpected scaling events, new resource deployments, or pricing changes.'
+ })
+ }
+
+ # --- High: Missing required tags ---
+ if ($d.TagRecs -and $d.TagRecs.MissingRequired.Count -gt 0) {
+ $names = ($d.TagRecs.MissingRequired | ForEach-Object { $_.TagName }) -join ', '
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 2; Impact = 'High'; Category = 'Allocation'
+ Title = "Deploy missing required tags: $names"
+ Detail = 'Microsoft Cloud Adoption Framework requires these tags for chargeback/showback. Use the Tags tab to deploy them to subscriptions or resource groups.'
+ })
+ }
+
+ # --- High: No FinOps policies ---
+ if ($d.PolicyRecs -and $d.PolicyRecs.Missing.Count -gt 0) {
+ $missingCount = $d.PolicyRecs.Missing.Count
+ $totalPolicies = $d.PolicyRecs.Analysis.Count
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 2; Impact = 'High'; Category = 'Governance'
+ Title = "Deploy $missingCount of $totalPolicies recommended FinOps policies"
+ Detail = 'Azure Policy enforces cost governance at scale. Start with Audit mode to measure impact, then move to Deny for critical policies like allowed VM sizes and required tags. Use the Policy tab to deploy.'
+ })
+ }
+
+ # --- Medium: AHB opportunities ---
+ if ($d.AHB -and $d.AHB.TotalOpportunities -gt 0) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 3; Impact = 'Medium'; Category = 'Optimization'
+ Title = "Enable Azure Hybrid Benefit on $($d.AHB.TotalOpportunities) resource(s)"
+ Detail = 'If you have existing Windows Server or SQL Server licenses with Software Assurance, AHB saves 40-85% on compute. This is free money with no architectural changes.'
+ })
+ }
+
+ # --- Medium: Advisor recommendations ---
+ if ($d.Optimization -and $d.Optimization.TotalCount -gt 0) {
+ $estSavings = $d.Optimization.EstimatedAnnualSavings.ToString('N2')
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 3; Impact = 'Medium'; Category = 'Optimization'
+ Title = "$($d.Optimization.TotalCount) Advisor cost recommendations (est. $currency$estSavings/yr)"
+ Detail = 'Review Azure Advisor recommendations on the Optimization tab. Common quick wins: rightsize VMs, delete unused resources, shut down dev/test outside business hours.'
+ })
+ }
+
+ # --- Medium: Orphaned resources ---
+ if ($d.Orphans) {
+ $orphanTotal = if ($d.Orphans.TotalCount) { $d.Orphans.TotalCount } else { 0 }
+ if ($orphanTotal -gt 0) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 3; Impact = 'Medium'; Category = 'Optimization'
+ Title = "Clean up $orphanTotal orphaned/idle resource(s)"
+ Detail = 'Orphaned disks, unattached IPs, deallocated VMs, and empty App Service Plans cost money but serve no purpose. Review on the Optimization tab.'
+ })
+ }
+ }
+
+ # --- Medium: Reservation/SP advice ---
+ if ($d.Reservations -and ($d.Reservations.TotalAdvisorCount + $d.Reservations.TotalReservationCount) -gt 0) {
+ $riSavings = $d.Reservations.EstimatedAnnualSavings.ToString('N2')
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 3; Impact = 'Medium'; Category = 'Optimization'
+ Title = "Evaluate RI/Savings Plan opportunities (est. $currency$riSavings/yr)"
+ Detail = 'For steady-state workloads, Reserved Instances save 30-72% vs. pay-as-you-go. Savings Plans offer flexibility across VM families. Start with 1-year terms to reduce risk.'
+ })
+ }
+
+ # --- Lower: Commitment utilization ---
+ if ($d.Commitments -and $d.Commitments.HasData -and $d.Commitments.UnderutilizedRIs.Count -gt 0) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 4; Impact = 'Low'; Category = 'Optimization'
+ Title = "$($d.Commitments.UnderutilizedRIs.Count) underutilized reservation(s) (below 80%)"
+ Detail = 'Exchange or refund underperforming reservations. Azure allows one-time exchanges to better-fitting SKUs or regions. Target 80%+ utilization on all commitments.'
+ })
+ }
+
+ # --- No MG hierarchy = flat org ---
+ if (-not $d.Hierarchy -or -not $d.Hierarchy.RootGroup) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 4; Impact = 'Low'; Category = 'Governance'
+ Title = 'Set up Management Group hierarchy'
+ Detail = 'Management Groups enable policy inheritance and cost rollup at the organizational level. Structure as: Tenant Root > Platform / Landing Zones > Production / Dev / Sandbox.'
+ })
+ }
+
+ # --- Positive: Add encouragement for things done well ---
+ if ($d.Budgets -and $d.Budgets.HasData -and $d.Budgets.BudgetCoverage -ge 80) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 10; Impact = 'Strength'; Category = 'Budgeting'
+ Title = "Budget coverage is $($d.Budgets.BudgetCoverage)% - well governed"
+ Detail = 'Consider adding action groups that auto-scale down or shut off dev resources when budgets hit 90%.'
+ })
+ }
+ if ($d.Tags -and $d.Tags.TagCoverage -ge 80) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 10; Impact = 'Strength'; Category = 'Allocation'
+ Title = "Tag coverage at $($d.Tags.TagCoverage)% - strong cost allocation"
+ Detail = 'Next step: implement tag-based cost allocation rules in Cost Management to automatically distribute shared costs to business units.'
+ })
+ }
+ if ($d.PolicyInv -and $d.PolicyInv.AssignmentCount -gt 5) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 10; Impact = 'Strength'; Category = 'Governance'
+ Title = "$($d.PolicyInv.AssignmentCount) policies in place - governance foundation established"
+ Detail = 'Review compliance % on the Policy tab. Move Audit-mode policies to Deny for critical rules once compliance is above 90%.'
+ })
+ }
+ if ($d.Savings -and $d.Savings.TotalMonthly -gt 0) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 10; Impact = 'Strength'; Category = 'Optimization'
+ Title = "Already saving $currency$($d.Savings.TotalMonthly.ToString('N2'))/mo from commitments"
+ Detail = 'Great foundation. Monitor utilization monthly and consider expanding coverage as workloads stabilize.'
+ })
+ }
+
+ # Fall back if nothing
+ if ($actions.Count -eq 0) {
+ [void]$actions.Add([PSCustomObject]@{
+ Priority = 5; Impact = 'Info'; Category = 'General'
+ Title = 'Run a full scan with Cost Management Reader permissions for detailed recommendations'
+ Detail = 'The scanner needs cost and policy data to generate specific actions. Ensure the account has Reader + Cost Management Reader at the management group or subscription scope.'
+ })
+ }
+
+ # Sort: Critical first, Strength last
+ $sortedActions = @($actions | Sort-Object Priority, Category)
+ $impactToColor = @{
+ Critical = '#D13438'; High = '#FF8C00'; Medium = '#0078D4'
+ Low = '#666'; Info = '#888'; Strength = '#107C10'
+ }
+
+ $subtitle = "Based on your scan results, here are $($sortedActions.Count) recommendations in priority order."
+ if ($score -ge 70) { $subtitle += ' Your environment is in good shape - focus on the refinements below.' }
+ elseif ($score -ge 50) { $subtitle += ' You have a solid foundation - the items below will accelerate FinOps maturity.' }
+ else { $subtitle += ' Start with the Critical and High-impact items to build your FinOps foundation.' }
+ $script:ActionPlanSubtitle.Text = $subtitle
+
+ $actionNum = 0
+ foreach ($a in $sortedActions) {
+ $actionNum++
+ $color = if ($impactToColor.ContainsKey($a.Impact)) { $impactToColor[$a.Impact] } else { '#444' }
+
+ $actionBorder = [System.Windows.Controls.Border]::new()
+ $actionBorder.Background = [System.Windows.Media.Brushes]::White
+ $actionBorder.CornerRadius = [System.Windows.CornerRadius]::new(4)
+ $actionBorder.Padding = [System.Windows.Thickness]::new(14, 10, 14, 10)
+ $actionBorder.Margin = [System.Windows.Thickness]::new(0, 0, 0, 6)
+ $actionBorder.BorderBrush = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#E8E8E8')
+ $actionBorder.BorderThickness = [System.Windows.Thickness]::new(1)
+
+ $actionStack = [System.Windows.Controls.StackPanel]::new()
+
+ # Title line: #1 [Critical] Title
+ $titleLine = [System.Windows.Controls.TextBlock]::new()
+ $titleLine.TextWrapping = 'Wrap'
+ $titleLine.FontSize = 13
+ $titleLine.Margin = [System.Windows.Thickness]::new(0, 0, 0, 4)
+
+ $numRun = [System.Windows.Documents.Run]::new("#$actionNum ")
+ $numRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#999')
+ $numRun.FontWeight = 'Bold'
+ $titleLine.Inlines.Add($numRun) | Out-Null
+
+ $tagRun = [System.Windows.Documents.Run]::new("[$($a.Impact)] ")
+ $tagRun.FontWeight = 'Bold'
+ $tagRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString($color)
+ $titleLine.Inlines.Add($tagRun) | Out-Null
+
+ $titleRun = [System.Windows.Documents.Run]::new($a.Title)
+ $titleRun.FontWeight = 'SemiBold'
+ $titleRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#222')
+ $titleLine.Inlines.Add($titleRun) | Out-Null
+
+ $actionStack.Children.Add($titleLine) | Out-Null
+
+ # Detail line
+ $detailTb = [System.Windows.Controls.TextBlock]::new()
+ $detailTb.Text = $a.Detail
+ $detailTb.TextWrapping = 'Wrap'
+ $detailTb.FontSize = 12
+ $detailTb.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#555')
+ $actionStack.Children.Add($detailTb) | Out-Null
+
+ $actionBorder.Child = $actionStack
+ $script:ActionPlanPanel.Children.Add($actionBorder) | Out-Null
+ }
+
+ # =====================================================================
+ # UNDERSTAND PILLAR (rich formatted)
+ # =====================================================================
+ $script:UnderstandPanel.Children.Clear()
+ if ($d.Tags) {
+ if ($d.Tags.TagCoverage -lt 50) {
+ Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '!' -Bold 'CRITICAL:' -Normal "Only $($d.Tags.TagCoverage)% of resources are tagged. Target 80%+ for meaningful cost allocation. Use Azure Policy to auto-apply tags at resource creation." -Color '#D13438'
+ }
+ elseif ($d.Tags.TagCoverage -lt 80) {
+ Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '!' -Bold 'Tag coverage:' -Normal "$($d.Tags.TagCoverage)%. Good progress. Focus on the remaining untagged resources using tag inheritance policies." -Color '#FF8C00'
+ }
+ else {
+ Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '+' -Bold 'Tag coverage:' -Normal "$($d.Tags.TagCoverage)% - strong foundation for showback/chargeback." -Color '#107C10'
+ }
+ }
+ if ($d.TagRecs -and $d.TagRecs.MissingRequired.Count -gt 0) {
+ $names = ($d.TagRecs.MissingRequired | ForEach-Object { $_.TagName }) -join ', '
+ Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '!' -Bold 'Missing required tags:' -Normal "$names. These are essential for cost allocation per Microsoft CAF." -Color '#D13438'
+ }
+ if ($d.CostByTag -and $d.CostByTag.NoTagsFound) {
+ Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '!' -Bold 'No cost-allocation tags found.' -Normal 'All spend is unallocated. Finance teams cannot attribute costs to business units without CostCenter, Environment, or Application tags.' -Color '#D13438'
+ }
+ if ($d.Tags -and $d.Tags.TagCoverage -ge 80 -and ($d.TagRecs -and $d.TagRecs.MissingRequired.Count -eq 0)) {
+ Add-GuidanceLine -Panel $script:UnderstandPanel -Icon '+' -Bold 'Cost visibility is strong.' -Normal 'Tags are well-deployed and CAF-compliant. Consider implementing tag-based cost allocation rules for shared resources.' -Color '#107C10'
+ }
+
+ # =====================================================================
+ # QUANTIFY PILLAR (rich formatted)
+ # =====================================================================
+ $script:QuantifyPanel.Children.Clear()
+ $totalActual = 0; $totalForecast = 0
+ if ($d.Costs) {
+ foreach ($entry in $d.Costs.GetEnumerator()) {
+ $totalActual += $entry.Value.Actual
+ $totalForecast += $entry.Value.Forecast
+ }
+ }
+ $dayOfMonth = (Get-Date).Day
+ $daysInMonth = [DateTime]::DaysInMonth((Get-Date).Year, (Get-Date).Month)
+ $pctMonthElapsed = [math]::Round(($dayOfMonth / $daysInMonth) * 100, 0)
+
+ if ($dayOfMonth -le 3) {
+ Add-GuidanceLine -Panel $script:QuantifyPanel -Icon 'i' -Bold "Day $dayOfMonth of billing period ($pctMonthElapsed% elapsed)." -Normal 'Forecasts are less reliable this early. Check back after day 7 for more accurate projections.' -Color '#0078D4'
+ }
+ elseif ($dayOfMonth -le 7) {
+ Add-GuidanceLine -Panel $script:QuantifyPanel -Icon 'i' -Bold "Early in billing period (day $dayOfMonth)." -Normal 'Forecast accuracy improves after week 1.' -Color '#0078D4'
+ }
+ else {
+ if ($totalActual -gt 0 -and $totalForecast -gt $totalActual * 1.2) {
+ $increase = [math]::Round((($totalForecast - $totalActual) / $totalActual) * 100, 0)
+ Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '!' -Bold "Forecast is $increase% above MTD spend." -Normal "$currency$($totalForecast.ToString('N2')) projected vs $currency$($totalActual.ToString('N2')) actual on day $dayOfMonth/$daysInMonth. Review scaling patterns and set budget alerts." -Color '#FF8C00'
+ }
+ elseif ($totalForecast -gt 0) {
+ Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '+' -Bold 'Costs appear stable.' -Normal "Forecast $currency$($totalForecast.ToString('N2')) is within 20% of MTD spend on day $dayOfMonth/$daysInMonth." -Color '#107C10'
+ }
+ }
+ if ($totalForecast -gt 0) {
+ Add-GuidanceLine -Panel $script:QuantifyPanel -Icon 'i' -Bold "Current forecast:" -Normal "$currency$($totalForecast.ToString('N2')) for the full month (MTD actual: $currency$($totalActual.ToString('N2')))." -Color '#0078D4'
+ }
+ if (-not $d.Budgets -or -not $d.Budgets.HasData) {
+ Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '!' -Bold 'No Azure Budgets detected.' -Normal 'Set budgets at subscription or resource group level with 50%, 75%, 90%, 100% thresholds. Use action groups for email + auto-shutdown.' -Color '#D13438'
+ }
+ else {
+ Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '+' -Bold "Budget coverage: $($d.Budgets.BudgetCoverage)%." -Normal "$($d.Budgets.SubsWithBudget) subscription(s) have budgets configured." -Color '#107C10'
+ }
+ Add-GuidanceLine -Panel $script:QuantifyPanel -Icon '>' -Bold 'TIP:' -Normal 'Use Cost Management Exports to send daily/monthly cost data to a Storage Account for Power BI dashboards and FinOps reporting.' -Color '#8764B8'
+
+ # =====================================================================
+ # OPTIMIZE PILLAR (rich formatted)
+ # =====================================================================
+ $script:OptimizePanel.Children.Clear()
+ if ($d.AHB -and $d.AHB.TotalOpportunities -gt 0) {
+ Add-GuidanceLine -Panel $script:OptimizePanel -Icon '$' -Bold "$($d.AHB.TotalOpportunities) AHB opportunity(s)." -Normal 'Apply Azure Hybrid Benefit to save 40-85% if you have existing Windows/SQL licenses with Software Assurance. Zero architectural change required.' -Color '#107C10'
+ }
+ if ($d.Reservations -and ($d.Reservations.TotalAdvisorCount + $d.Reservations.TotalReservationCount) -gt 0) {
+ $riSavings = $d.Reservations.EstimatedAnnualSavings.ToString('N2')
+ Add-GuidanceLine -Panel $script:OptimizePanel -Icon '$' -Bold "RI/SP opportunities: est. $currency$riSavings/yr savings." -Normal 'For steady-state workloads, commit to 1-year terms first to reduce risk. Savings Plans offer VM family flexibility.' -Color '#107C10'
+ }
+ if ($d.Optimization -and $d.Optimization.TotalCount -gt 0) {
+ foreach ($cat in $d.Optimization.ByCategory) {
+ $catSavings = $cat.TotalSavings.ToString('N2')
+ Add-GuidanceLine -Panel $script:OptimizePanel -Icon '>' -Bold "$($cat.Count) $($cat.Category) recommendation(s)" -Normal "(est. $currency$catSavings/yr). Review details on the Optimization tab." -Color '#0078D4'
+ }
+ }
+ if ($d.Contract) {
+ $type = $d.Contract[0].AgreementType
+ if ($type -eq 'MicrosoftOnlineServicesProgram') {
+ Add-GuidanceLine -Panel $script:OptimizePanel -Icon '!' -Bold 'Pay-As-You-Go (PAYGO) account detected.' -Normal 'Consider an Enterprise Agreement (EA) or Microsoft Customer Agreement (MCA) for volume discounts, negotiated rates, and better cost management tooling.' -Color '#FF8C00'
+ }
+ }
+ if ($d.Savings -and $d.Savings.TotalMonthly -gt 0) {
+ Add-GuidanceLine -Panel $script:OptimizePanel -Icon '+' -Bold "Already saving $currency$($d.Savings.TotalMonthly.ToString('N2'))/mo" -Normal 'from existing reservations, savings plans, and/or AHB. Monitor utilization monthly.' -Color '#107C10'
+ }
+ if ($script:OptimizePanel.Children.Count -eq 0) {
+ Add-GuidanceLine -Panel $script:OptimizePanel -Icon '+' -Bold 'No major optimization gaps detected.' -Normal 'Continue monitoring Azure Advisor and Cost Management for new opportunities.' -Color '#107C10'
+ }
+
+ # =====================================================================
+ # PERSONAS - FinOps Foundation defined roles
+ # =====================================================================
+ $script:PersonasPanel.Children.Clear()
+ $personas = @(
+ @{ Role = 'FinOps Practitioner'; Desc = 'Drives the FinOps practice: runs cost reviews, manages tooling, builds reports, educates teams. Often the first hire for a FinOps program.'; When = 'Always needed' }
+ @{ Role = 'Engineering / DevOps Lead'; Desc = 'Implements rightsizing, AHB, auto-shutdown, and tagging at the resource level. Owns technical optimization actions.'; When = 'Always needed' }
+ @{ Role = 'Finance / Procurement'; Desc = 'Manages budgets, forecasts, commitment purchases (RIs/SPs), and licensing agreements. Owns the commercial relationship.'; When = 'Always needed' }
+ @{ Role = 'Executive Sponsor (VP/Director)'; Desc = 'Champions FinOps across the organization, breaks down silos between finance and engineering, approves commitment purchases.'; When = 'Critical for organizational buy-in' }
+ @{ Role = 'Cloud Architect'; Desc = 'Designs cost-efficient architectures, evaluates PaaS vs IaaS trade-offs, and ensures workloads are right-sized from the start.'; When = 'During design reviews and migrations' }
+ @{ Role = 'Business Unit Owners'; Desc = 'Consume cost reports (showback/chargeback), validate tag accuracy, and make build-vs-buy decisions for their teams.'; When = 'For cost allocation and accountability' }
+ )
+ foreach ($p in $personas) {
+ $personaTb = [System.Windows.Controls.TextBlock]::new()
+ $personaTb.TextWrapping = 'Wrap'
+ $personaTb.FontSize = 12.5
+ $personaTb.Margin = [System.Windows.Thickness]::new(0, 0, 0, 8)
+
+ $roleRun = [System.Windows.Documents.Run]::new("$($p.Role): ")
+ $roleRun.FontWeight = 'Bold'
+ $roleRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#222')
+ $personaTb.Inlines.Add($roleRun) | Out-Null
+
+ $descRun = [System.Windows.Documents.Run]::new($p.Desc)
+ $descRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#444')
+ $personaTb.Inlines.Add($descRun) | Out-Null
+
+ $whenRun = [System.Windows.Documents.Run]::new(" ($($p.When))")
+ $whenRun.FontStyle = 'Italic'
+ $whenRun.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#888')
+ $personaTb.Inlines.Add($whenRun) | Out-Null
+
+ $script:PersonasPanel.Children.Add($personaTb) | Out-Null
+ }
+}
+
+#-----------------------------------------------------------------------
+# COST TREND BAR CHART (pure WPF Canvas drawing)
+#-----------------------------------------------------------------------
+function Populate-TrendChart {
+ $d = $script:scanData.CostTrend
+ if (-not $d -or -not $d.HasData) {
+ $script:TrendNote.Text = "No cost trend data available."
+ return
+ }
+
+ # Populate subscription dropdown (only on first call)
+ if ($script:TrendSubSelector.Items.Count -eq 0) {
+ $script:TrendSubSelector.Items.Add('All Subscriptions') | Out-Null
+ if ($d.BySubscription -and $d.BySubscription.Count -gt 0) {
+ foreach ($sub in $script:scanData.Auth.Subscriptions) {
+ if ($d.BySubscription.ContainsKey($sub.Id)) {
+ $script:TrendSubSelector.Items.Add($sub.Name) | Out-Null
+ }
+ }
+ }
+ $script:TrendSubSelector.SelectedIndex = 0
+ }
+
+ Draw-TrendChart -Months $d.Months
+}
+
+function Draw-TrendChart {
+ param([object[]]$Months)
+
+ $canvas = $script:TrendChart
+ $canvas.Children.Clear()
+ $script:TrendNote.Text = ''
+
+ if (-not $Months -or $Months.Count -eq 0) {
+ $script:TrendNote.Text = 'No cost data for selected subscription.'
+ return
+ }
+
+ $months = $Months
+
+ $currency = if ($months[0].Currency) { Get-CurrencySymbol -Code $months[0].Currency } else { '$' }
+ $maxCost = ($months | Measure-Object -Property Cost -Maximum).Maximum
+ if ($maxCost -le 0) { $maxCost = 1 }
+
+ $canvasW = 900
+ $canvasH = 200
+ $barGap = 12
+ $labelH = 30
+ $chartH = $canvasH - $labelH
+ $barCount = $months.Count
+ $barW = [math]::Floor(($canvasW - ($barGap * ($barCount + 1))) / $barCount)
+ if ($barW -gt 120) { $barW = 120 }
+
+ $colors = @('#0078D4', '#005A9E', '#0063B1', '#2B88D8', '#106EBE', '#004578')
+
+ for ($i = 0; $i -lt $barCount; $i++) {
+ $m = $months[$i]
+ $barH = [math]::Max(([math]::Round(($m.Cost / $maxCost) * $chartH, 0)), 2)
+ $x = $barGap + ($i * ($barW + $barGap))
+ $y = $chartH - $barH
+
+ # Bar rectangle
+ $rect = [System.Windows.Shapes.Rectangle]::new()
+ $rect.Width = $barW
+ $rect.Height = $barH
+ $rect.Fill = [System.Windows.Media.BrushConverter]::new().ConvertFromString($colors[$i % $colors.Count])
+ $rect.RadiusX = 3
+ $rect.RadiusY = 3
+ [System.Windows.Controls.Canvas]::SetLeft($rect, $x)
+ [System.Windows.Controls.Canvas]::SetTop($rect, $y)
+ $canvas.Children.Add($rect) | Out-Null
+
+ # Cost label above bar (or inside bar if it would clip above canvas)
+ $costLabel = [System.Windows.Controls.TextBlock]::new()
+ $costLabel.Text = "$currency$($m.Cost.ToString('N0'))"
+ $costLabel.FontSize = 10
+ $costLabel.TextAlignment = 'Center'
+ $costLabel.Width = $barW
+ $labelTop = $y - 16
+ if ($labelTop -lt 0) {
+ # Place label inside the top of the bar with white text
+ $labelTop = $y + 4
+ $costLabel.Foreground = [System.Windows.Media.Brushes]::White
+ $costLabel.FontWeight = 'SemiBold'
+ }
+ else {
+ $costLabel.Foreground = [System.Windows.Media.Brushes]::Gray
+ }
+ [System.Windows.Controls.Canvas]::SetLeft($costLabel, $x)
+ [System.Windows.Controls.Canvas]::SetTop($costLabel, $labelTop)
+ $canvas.Children.Add($costLabel) | Out-Null
+
+ # Month label below bar
+ $monthLabel = [System.Windows.Controls.TextBlock]::new()
+ $monthLabel.Text = $m.Month
+ $monthLabel.FontSize = 10
+ $monthLabel.FontWeight = 'SemiBold'
+ $monthLabel.Foreground = [System.Windows.Media.Brushes]::DimGray
+ $monthLabel.TextAlignment = 'Center'
+ $monthLabel.Width = $barW
+ [System.Windows.Controls.Canvas]::SetLeft($monthLabel, $x)
+ [System.Windows.Controls.Canvas]::SetTop($monthLabel, $chartH + 4)
+ $canvas.Children.Add($monthLabel) | Out-Null
+ }
+
+ # Trend note
+ $firstCost = $months[0].Cost
+ $lastCost = $months[$months.Count - 1].Cost
+ if ($firstCost -gt 0) {
+ $changePct = [math]::Round((($lastCost - $firstCost) / $firstCost) * 100, 1)
+ $direction = if ($changePct -gt 0) { "up" } elseif ($changePct -lt 0) { "down" } else { "flat" }
+ $script:TrendNote.Text = "6-month trend: $currency$($firstCost.ToString('N2')) -> $currency$($lastCost.ToString('N2')) ($direction $([math]::Abs($changePct))%)"
+ }
+ else {
+ $script:TrendNote.Text = ""
+ }
+}
+
+# Trend subscription dropdown handler
+$script:TrendSubSelector.Add_SelectionChanged({
+ $d = $script:scanData.CostTrend
+ if (-not $d -or -not $d.HasData) { return }
+
+ $selectedIdx = $script:TrendSubSelector.SelectedIndex
+ if ($selectedIdx -le 0) {
+ # All subscriptions
+ Draw-TrendChart -Months $d.Months
+ }
+ else {
+ $selectedName = $script:TrendSubSelector.SelectedItem
+ $sub = $script:scanData.Auth.Subscriptions | Where-Object { $_.Name -eq $selectedName } | Select-Object -First 1
+ if ($sub -and $d.BySubscription -and $d.BySubscription.ContainsKey($sub.Id)) {
+ Draw-TrendChart -Months $d.BySubscription[$sub.Id]
+ }
+ else {
+ Draw-TrendChart -Months @()
+ }
+ }
+ })
+
+#-----------------------------------------------------------------------
+# TAG DEPLOYMENT UI WIRING
+#-----------------------------------------------------------------------
+$script:tagDeployCurrentTag = $null
+$script:tagDeployScopesLoaded = $false
+$script:tagDeployScopes = @()
+$script:tagRemoveMode = $false
+$script:tagCustomMode = $false
+
+function Load-TagScopes {
+ if (-not $script:tagDeployScopesLoaded -and $script:scanData.Auth) {
+ $script:TagDeployStatus.Text = 'Loading scopes...'
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [action] {}, [System.Windows.Threading.DispatcherPriority]::Background
+ )
+ $script:tagDeployScopes = Get-TagScopes -Subscriptions $script:scanData.Auth.Subscriptions
+ $script:tagDeployScopesLoaded = $true
+ $script:TagDeployStatus.Text = ''
+ }
+
+ $script:TagScopeSelector.Items.Clear()
+ foreach ($s in $script:tagDeployScopes) {
+ $script:TagScopeSelector.Items.Add($s.DisplayName) | Out-Null
+ }
+ if ($script:tagDeployScopes.Count -gt 0) {
+ $script:TagScopeSelector.SelectedIndex = 0
+ }
+}
+
+function Show-TagDeployPanel {
+ param([string]$TagName)
+
+ $script:tagDeployCurrentTag = $TagName
+ $script:tagRemoveMode = $false
+ $script:tagCustomMode = $false
+ $script:TagDeployTitle.Text = "Deploy tag: $TagName"
+ $script:TagDeployStatus.Text = ''
+ $script:TagValueInput.Text = ''
+ $script:TagValueInput.Visibility = 'Visible'
+ $script:TagNameInput.Visibility = 'Collapsed'
+ $script:TagNameLabel.Visibility = 'Collapsed'
+ # Show the tag value label
+ $valIdx = $script:TagDeployPanel.Child.Children.IndexOf($script:TagValueInput)
+ if ($valIdx -gt 0) {
+ $script:TagDeployPanel.Child.Children[$valIdx - 1].Visibility = 'Visible'
+ }
+ $script:TagDeployButton.Content = 'Deploy Tag'
+ $script:TagDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#0078D4')
+ $script:TagDeployPanel.Visibility = 'Visible'
+
+ Load-TagScopes
+}
+
+function Show-CustomTagDeployPanel {
+ $script:tagDeployCurrentTag = $null
+ $script:tagRemoveMode = $false
+ $script:tagCustomMode = $true
+ $script:TagDeployTitle.Text = "Deploy Custom Tag"
+ $script:TagDeployStatus.Text = ''
+ $script:TagNameInput.Text = ''
+ $script:TagNameInput.Visibility = 'Visible'
+ $script:TagNameLabel.Visibility = 'Visible'
+ $script:TagValueInput.Text = ''
+ $script:TagValueInput.Visibility = 'Visible'
+ $valIdx = $script:TagDeployPanel.Child.Children.IndexOf($script:TagValueInput)
+ if ($valIdx -gt 0) {
+ $script:TagDeployPanel.Child.Children[$valIdx - 1].Visibility = 'Visible'
+ }
+ $script:TagDeployButton.Content = 'Deploy Tag'
+ $script:TagDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#0078D4')
+ $script:TagDeployPanel.Visibility = 'Visible'
+
+ Load-TagScopes
+}
+
+function Show-TagRemovePanel {
+ param([string]$TagName)
+
+ $script:tagDeployCurrentTag = $TagName
+ $script:tagRemoveMode = $true
+ $script:tagCustomMode = $false
+ $script:TagDeployTitle.Text = "Remove tag: $TagName"
+ $script:TagDeployStatus.Text = ''
+ $script:TagNameInput.Visibility = 'Collapsed'
+ $script:TagNameLabel.Visibility = 'Collapsed'
+
+ # Show value input as optional filter
+ $valIdx = $script:TagDeployPanel.Child.Children.IndexOf($script:TagValueInput)
+ if ($valIdx -gt 0) {
+ $script:TagDeployPanel.Child.Children[$valIdx - 1].Text = 'Value Filter (blank = all values):'
+ $script:TagDeployPanel.Child.Children[$valIdx - 1].Visibility = 'Visible'
+ }
+ $script:TagValueInput.Text = ''
+ $script:TagValueInput.Visibility = 'Visible'
+ $script:TagDeployButton.Content = 'Remove Tag'
+ $script:TagDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D13438')
+ $script:TagDeployPanel.Visibility = 'Visible'
+
+ Load-TagScopes
+
+ # Insert "All Scopes" entries at the top of the scope selector for mass removal
+ # One per subscription: removes from the sub + all its RGs in a single click
+ $allEntries = @()
+ foreach ($sub in $script:scanData.Auth.Subscriptions) {
+ $allEntries += [PSCustomObject]@{
+ DisplayName = "[ALL] $($sub.Name) (Sub + all RGs)"
+ SubId = $sub.Id
+ }
+ }
+ # Insert at position 0 so they appear first
+ for ($i = $allEntries.Count - 1; $i -ge 0; $i--) {
+ $script:TagScopeSelector.Items.Insert(0, $allEntries[$i].DisplayName)
+ }
+ # Track in a script-scoped list so the handler knows which indices are "all" entries
+ $script:tagRemoveAllEntries = $allEntries
+ $script:TagScopeSelector.SelectedIndex = 0
+}
+
+#-----------------------------------------------------------------------
+# POLICY TAB POPULATION
+#-----------------------------------------------------------------------
+function Populate-PolicyTab {
+ $d = $script:scanData
+
+ # Summary cards
+ if ($d.PolicyInv) {
+ $script:PolicyCountText.Text = $d.PolicyInv.AssignmentCount.ToString()
+ $script:PolicyComplianceText.Text = "$($d.PolicyInv.CompliancePct)%"
+ $script:PolicyNonCompliantText.Text = $d.PolicyInv.TotalNonCompliant.ToString('N0')
+
+ # Assignment inventory grid with inline Unassign button
+ $script:PolicyInventoryGrid.AutoGenerateColumns = $false
+ $script:PolicyInventoryGrid.Columns.Clear()
+
+ foreach ($col in @('Assignment Name', 'Type', 'Effect', 'Enforcement', 'Origin', 'Subscription', 'Scope')) {
+ $dgCol = [System.Windows.Controls.DataGridTextColumn]::new()
+ $dgCol.Header = $col
+ $dgCol.Binding = [System.Windows.Data.Binding]::new($col)
+ if ($col -in @('Assignment Name', 'Scope')) {
+ $dgCol.Width = [System.Windows.Controls.DataGridLength]::new(1, [System.Windows.Controls.DataGridLengthUnitType]::Star)
+ $dgCol.ElementStyle = [System.Windows.Style]::new([System.Windows.Controls.TextBlock])
+ $dgCol.ElementStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::Wrap))
+ }
+ $script:PolicyInventoryGrid.Columns.Add($dgCol)
+ }
+
+ # Unassign button template column
+ $actionCol = [System.Windows.Controls.DataGridTemplateColumn]::new()
+ $actionCol.Header = 'Action'
+ $actionCol.Width = 75
+
+ $cellFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.Button])
+ $cellFactory.SetValue([System.Windows.Controls.Button]::ContentProperty, 'Unassign')
+ $cellFactory.SetBinding([System.Windows.Controls.Button]::TagProperty, [System.Windows.Data.Binding]::new('AssignmentIndex'))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::FontSizeProperty, [double]10)
+ $cellFactory.SetValue([System.Windows.Controls.Button]::PaddingProperty, [System.Windows.Thickness]::new(6, 1, 6, 1))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::MarginProperty, [System.Windows.Thickness]::new(2, 1, 2, 1))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::CursorProperty, [System.Windows.Input.Cursors]::Hand)
+ $cellFactory.SetValue([System.Windows.Controls.Button]::BackgroundProperty, [System.Windows.Media.BrushConverter]::new().ConvertFromString('#FDE7E9'))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::ForegroundProperty, [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D13438'))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::BorderThicknessProperty, [System.Windows.Thickness]::new(1))
+ $cellFactory.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler] {
+ param($sender, $e)
+ $idx = [int]$sender.Tag
+ $assignment = $script:scanData.PolicyInv.Assignments[$idx]
+ $displayName = $assignment.AssignmentName
+ $policyDefId = $assignment.PolicyDefId
+
+ # Find ALL assignments with the same PolicyDefId (same policy assigned multiple times)
+ $matchingAssignments = @($script:scanData.PolicyInv.Assignments | Where-Object {
+ $_.PolicyDefId -and $policyDefId -and $_.PolicyDefId.ToLower() -eq $policyDefId.ToLower()
+ })
+
+ $matchCount = $matchingAssignments.Count
+ $statusLabel = if ($matchCount -gt 1) { "Removing $matchCount assignments of this policy..." } else { "Removing assignment..." }
+
+ $script:PolicyDeployTitle.Text = "Unassign: $displayName"
+ $script:PolicyDeployStatus.Text = $statusLabel
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.Brushes]::Gray
+ $script:PolicyDeployPanel.Visibility = 'Visible'
+ $script:PolicyScopeSelector.Visibility = 'Collapsed'
+ $script:PolicyEffectSelector.Visibility = 'Collapsed'
+ $script:PolicyParamsPanel.Visibility = 'Collapsed'
+ $script:PolicyRemediateButton.Visibility = 'Collapsed'
+ foreach ($ctrl in @($script:PolicyScopeSelector, $script:PolicyEffectSelector)) {
+ $parent = $ctrl.Parent
+ if ($parent) {
+ $ctrlIdx = $parent.Children.IndexOf($ctrl)
+ if ($ctrlIdx -gt 0) { $parent.Children[$ctrlIdx - 1].Visibility = 'Collapsed' }
+ }
+ }
+ $script:PolicyDeployButton.Visibility = 'Collapsed'
+
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [System.Windows.Threading.DispatcherPriority]::Render, [action] {})
+
+ $successCount = 0
+ $failMsg = ''
+ foreach ($ma in $matchingAssignments) {
+ try {
+ $result = Remove-PolicyAssignment -AssignmentId $ma.AssignmentId
+ if ($result.Success) {
+ $successCount++
+ }
+ else {
+ $failMsg = $result.Message
+ }
+ }
+ catch {
+ $failMsg = $_.Exception.Message
+ }
+ }
+
+ if ($successCount -eq $matchCount) {
+ $script:PolicyDeployStatus.Text = "Unassigned: $displayName ($successCount assignment(s) removed)"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#107C10')
+ $script:actionLog.Add([PSCustomObject]@{ Time = (Get-Date -Format 'HH:mm:ss'); Type = 'Policy Unassigned'; Detail = "$displayName ($successCount removed)" })
+ # Disable all matching buttons in the grid
+ $sender.Content = 'Removed'
+ $sender.IsEnabled = $false
+ }
+ elseif ($successCount -gt 0) {
+ $script:PolicyDeployStatus.Text = "Partial: $successCount of $matchCount removed. Error: $failMsg"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ else {
+ $script:PolicyDeployStatus.Text = "Failed: $failMsg"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ $script:PolicyDeployButton.Visibility = 'Visible'
+ })
+
+ $cellTemplate = [System.Windows.DataTemplate]::new()
+ $cellTemplate.VisualTree = $cellFactory
+ $actionCol.CellTemplate = $cellTemplate
+ $script:PolicyInventoryGrid.Columns.Add($actionCol)
+
+ $idx = 0
+ $invRows = $d.PolicyInv.Assignments | ForEach-Object {
+ $type = if ($_.PolicyDefId -match '/policySetDefinitions/') { 'Initiative' } else { 'Policy' }
+ $row = [PSCustomObject]@{
+ 'Assignment Name' = $_.AssignmentName
+ 'Type' = $type
+ 'Effect' = $_.Effect
+ 'Enforcement' = $_.EnforcementMode
+ 'Origin' = $_.Origin
+ 'Subscription' = $_.Subscription
+ 'Scope' = if ($_.Scope.Length -gt 60) { '...' + $_.Scope.Substring($_.Scope.Length - 57) } else { $_.Scope }
+ 'AssignmentIndex' = $idx
+ }
+ $idx++
+ $row
+ }
+ $script:PolicyInventoryGrid.ItemsSource = @($invRows)
+
+ # Per-subscription compliance grid
+ $compRows = $d.PolicyInv.ComplianceBySubMap.Values | ForEach-Object {
+ [PSCustomObject]@{
+ 'Subscription' = $_.Subscription
+ 'Compliant' = $_.Compliant
+ 'Non-Compliant' = $_.NonCompliant
+ 'Total Evaluated' = $_.TotalResources
+ 'Compliance %' = if (($_.Compliant + $_.NonCompliant) -gt 0) {
+ [math]::Round(($_.Compliant / ($_.Compliant + $_.NonCompliant)) * 100, 1).ToString() + '%'
+ }
+ else { '-' }
+ }
+ }
+ $script:PolicyComplianceGrid.ItemsSource = @($compRows)
+ }
+
+ # Policy recommendations with inline action buttons
+ if ($d.PolicyRecs) {
+ $assignedCount = $d.PolicyRecs.Assigned.Count
+ $analysisCount = $d.PolicyRecs.Analysis.Count
+ $script:PolicyRecsCountText.Text = "$assignedCount / $analysisCount"
+ $script:PolicyRecsComplianceText.Text = "CAF policy coverage: $($d.PolicyRecs.CompliancePct)% ($assignedCount of $analysisCount recommended policies assigned)"
+
+ # Build the policy recs grid with programmatic columns including an Action button
+ $script:PolicyRecsGrid.AutoGenerateColumns = $false
+ $script:PolicyRecsGrid.Columns.Clear()
+
+ # Data columns
+ foreach ($col in @('Policy', 'Status', 'Category', 'Priority', 'Pillar', 'Effect', 'Purpose')) {
+ $dgCol = [System.Windows.Controls.DataGridTextColumn]::new()
+ $dgCol.Header = $col
+ $dgCol.Binding = [System.Windows.Data.Binding]::new($col)
+ if ($col -eq 'Purpose') {
+ $dgCol.Width = [System.Windows.Controls.DataGridLength]::new(1, [System.Windows.Controls.DataGridLengthUnitType]::Star)
+ $dgCol.ElementStyle = [System.Windows.Style]::new([System.Windows.Controls.TextBlock])
+ $dgCol.ElementStyle.Setters.Add([System.Windows.Setter]::new([System.Windows.Controls.TextBlock]::TextWrappingProperty, [System.Windows.TextWrapping]::Wrap))
+ }
+ $script:PolicyRecsGrid.Columns.Add($dgCol)
+ }
+
+ # Action button template column
+ $actionCol = [System.Windows.Controls.DataGridTemplateColumn]::new()
+ $actionCol.Header = 'Action'
+ $actionCol.Width = 85
+
+ $cellFactory = [System.Windows.FrameworkElementFactory]::new([System.Windows.Controls.Button])
+ $cellFactory.SetBinding([System.Windows.Controls.Button]::ContentProperty, [System.Windows.Data.Binding]::new('ActionLabel'))
+ $cellFactory.SetBinding([System.Windows.Controls.Button]::BackgroundProperty, [System.Windows.Data.Binding]::new('ActionBg'))
+ $cellFactory.SetBinding([System.Windows.Controls.Button]::ForegroundProperty, [System.Windows.Data.Binding]::new('ActionFg'))
+ $cellFactory.SetBinding([System.Windows.Controls.Button]::TagProperty, [System.Windows.Data.Binding]::new('PolicyIndex'))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::FontSizeProperty, [double]10)
+ $cellFactory.SetValue([System.Windows.Controls.Button]::PaddingProperty, [System.Windows.Thickness]::new(6, 1, 6, 1))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::MarginProperty, [System.Windows.Thickness]::new(2, 1, 2, 1))
+ $cellFactory.SetValue([System.Windows.Controls.Button]::CursorProperty, [System.Windows.Input.Cursors]::Hand)
+ $cellFactory.SetValue([System.Windows.Controls.Button]::BorderThicknessProperty, [System.Windows.Thickness]::new(1))
+ $cellFactory.AddHandler([System.Windows.Controls.Button]::ClickEvent, [System.Windows.RoutedEventHandler] {
+ param($sender, $e)
+ $idx = [int]$sender.Tag
+ $pol = $script:scanData.PolicyRecs.Analysis[$idx]
+ if ($pol.Status -eq 'Missing') {
+ $polParams = if ($pol.Parameters) { $pol.Parameters } else { @() }
+ Show-PolicyDeployPanel -PolicyDisplayName $pol.DisplayName -PolicyDefId $pol.PolicyDefId -AllowedEffects $pol.AllowedEffects -DefaultEffect $pol.DefaultEffect -Parameters $polParams
+ }
+ else {
+ Show-PolicyUnassignPanel -PolicyDisplayName $pol.DisplayName -PolicyDefId $pol.PolicyDefId
+ }
+ })
+
+ $cellTemplate = [System.Windows.DataTemplate]::new()
+ $cellTemplate.VisualTree = $cellFactory
+ $actionCol.CellTemplate = $cellTemplate
+ $script:PolicyRecsGrid.Columns.Add($actionCol)
+
+ # Populate rows with action metadata
+ $brushConv = [System.Windows.Media.BrushConverter]::new()
+ $idx = 0
+ $recRows = $d.PolicyRecs.Analysis | ForEach-Object {
+ $isMissing = $_.Status -eq 'Missing'
+ $row = [PSCustomObject]@{
+ 'Policy' = $_.DisplayName
+ 'Status' = $_.Status
+ 'Category' = $_.Category
+ 'Priority' = $_.Priority
+ 'Pillar' = $_.Pillar
+ 'Effect' = $_.DefaultEffect
+ 'Purpose' = $_.Purpose
+ 'PolicyIndex' = $idx
+ 'ActionLabel' = if ($isMissing) { 'Deploy' } else { 'Unassign' }
+ 'ActionBg' = if ($isMissing) { $brushConv.ConvertFromString('#DFF6DD') } else { $brushConv.ConvertFromString('#FDE7E9') }
+ 'ActionFg' = if ($isMissing) { $brushConv.ConvertFromString('#107C10') } else { $brushConv.ConvertFromString('#D13438') }
+ }
+ $idx++
+ $row
+ }
+ $script:PolicyRecsGrid.ItemsSource = @($recRows)
+ }
+}
+
+function Show-PolicyDeployPanel {
+ param(
+ [string]$PolicyDisplayName,
+ [string]$PolicyDefId,
+ [string[]]$AllowedEffects,
+ [string]$DefaultEffect,
+ [object[]]$Parameters = @()
+ )
+
+ $script:policyDeployCurrentDefId = $PolicyDefId
+ $script:policyDeployCurrentName = $PolicyDisplayName
+ $script:policyDeployCurrentParams = $Parameters
+ $script:policyUnassignMode = $false
+ $script:PolicyDeployTitle.Text = "Deploy policy: $PolicyDisplayName"
+ $script:PolicyDeployStatus.Text = ''
+ $script:PolicyDeployPanel.Visibility = 'Visible'
+ $script:PolicyDeployButton.Content = 'Deploy Policy'
+ $script:PolicyDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#0078D4')
+
+ # Ensure scope/effect/params are visible (may have been hidden by unassign)
+ $script:PolicyScopeSelector.Visibility = 'Visible'
+ $script:PolicyEffectSelector.Visibility = 'Visible'
+ $script:PolicyParamsPanel.Visibility = 'Visible'
+ foreach ($ctrl in @($script:PolicyScopeSelector, $script:PolicyEffectSelector)) {
+ $parent = $ctrl.Parent
+ if ($parent) {
+ $idx = $parent.Children.IndexOf($ctrl)
+ if ($idx -gt 0) { $parent.Children[$idx - 1].Visibility = 'Visible' }
+ }
+ }
+
+ # Populate effect selector
+ $script:PolicyEffectSelector.Items.Clear()
+ foreach ($eff in $AllowedEffects) {
+ $script:PolicyEffectSelector.Items.Add($eff) | Out-Null
+ }
+ # Pre-select default (Audit for safety)
+ $safeDefault = if ($AllowedEffects -contains 'Audit') { 'Audit' } else { $DefaultEffect }
+ $idx = [Array]::IndexOf($AllowedEffects, $safeDefault)
+ $script:PolicyEffectSelector.SelectedIndex = if ($idx -ge 0) { $idx } else { 0 }
+
+ # Build dynamic parameter inputs
+ $script:PolicyParamsPanel.Children.Clear()
+ $script:policyParamTextBoxes = @{}
+ if ($Parameters -and $Parameters.Count -gt 0) {
+ foreach ($p in $Parameters) {
+ $lbl = [System.Windows.Controls.TextBlock]::new()
+ $lbl.Text = "$($p.Label)$(if ($p.Required) { ' *' } else { '' }):"
+ $lbl.FontSize = 12
+ $lbl.Margin = [System.Windows.Thickness]::new(0, 0, 0, 4)
+ $script:PolicyParamsPanel.Children.Add($lbl) | Out-Null
+
+ $tb = [System.Windows.Controls.TextBox]::new()
+ $tb.Width = 500
+ $tb.HorizontalAlignment = 'Left'
+ $tb.FontSize = 12
+ $tb.Padding = [System.Windows.Thickness]::new(6, 4, 6, 4)
+ $tb.Margin = [System.Windows.Thickness]::new(0, 0, 0, 10)
+ $script:PolicyParamsPanel.Children.Add($tb) | Out-Null
+ $script:policyParamTextBoxes[$p.Name] = @{ TextBox = $tb; Param = $p }
+ }
+ }
+
+ # Load scopes lazily (once per scan)
+ if (-not $script:policyDeployScopesLoaded -and $script:scanData.Auth) {
+ $script:PolicyDeployStatus.Text = 'Loading scopes...'
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [action] {}, [System.Windows.Threading.DispatcherPriority]::Background
+ )
+ $script:policyDeployScopes = Get-PolicyScopes -Subscriptions $script:scanData.Auth.Subscriptions
+ $script:policyDeployScopesLoaded = $true
+ $script:PolicyDeployStatus.Text = ''
+ }
+
+ $script:PolicyScopeSelector.Items.Clear()
+ foreach ($s in $script:policyDeployScopes) {
+ $script:PolicyScopeSelector.Items.Add($s.DisplayName) | Out-Null
+ }
+ if ($script:policyDeployScopes.Count -gt 0) {
+ $script:PolicyScopeSelector.SelectedIndex = 0
+ }
+}
+
+function Show-PolicyUnassignPanel {
+ param(
+ [string]$PolicyDisplayName,
+ [string]$PolicyDefId
+ )
+
+ $script:policyDeployCurrentDefId = $PolicyDefId
+ $script:policyDeployCurrentName = $PolicyDisplayName
+ $script:policyUnassignMode = $true
+ $script:PolicyDeployTitle.Text = "Unassign policy: $PolicyDisplayName"
+ $script:PolicyDeployStatus.Text = ''
+ $script:PolicyDeployPanel.Visibility = 'Visible'
+ $script:PolicyRemediateButton.Visibility = 'Collapsed'
+
+ # Hide scope/effect/params (not needed for unassign)
+ $script:PolicyScopeSelector.Visibility = 'Collapsed'
+ $script:PolicyEffectSelector.Visibility = 'Collapsed'
+ $script:PolicyParamsPanel.Visibility = 'Collapsed'
+ # Hide their labels by finding previous siblings
+ foreach ($ctrl in @($script:PolicyScopeSelector, $script:PolicyEffectSelector)) {
+ $parent = $ctrl.Parent
+ if ($parent) {
+ $idx = $parent.Children.IndexOf($ctrl)
+ if ($idx -gt 0) { $parent.Children[$idx - 1].Visibility = 'Collapsed' }
+ }
+ }
+
+ $script:PolicyDeployButton.Content = 'Unassign Policy'
+ $script:PolicyDeployButton.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D13438')
+
+ # Find matching assignment(s) from inventory
+ $matchingAssignments = @()
+ if ($script:scanData.PolicyInv -and $script:scanData.PolicyInv.Assignments) {
+ $matchingAssignments = @($script:scanData.PolicyInv.Assignments | Where-Object {
+ $_.PolicyDefId -and $_.PolicyDefId.ToLower() -eq $PolicyDefId.ToLower()
+ })
+ }
+
+ if ($matchingAssignments.Count -eq 0) {
+ $script:PolicyDeployStatus.Text = "No assignment found for this policy in the inventory. It may be assigned with a different name."
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ return
+ }
+
+ # Store for the unassign handler
+ $script:policyUnassignTargets = $matchingAssignments
+ $count = $matchingAssignments.Count
+ $script:PolicyDeployStatus.Text = "$count assignment(s) found. Click Unassign to remove."
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.Brushes]::Gray
+}
+
+$script:policyUnassignMode = $false
+$script:policyUnassignTargets = @()
+
+#-----------------------------------------------------------------------
+# BILLING TAB POPULATION
+#-----------------------------------------------------------------------
+function Populate-BillingTab {
+ $d = $script:scanData.Billing
+
+ if (-not $d -or -not $d.HasBillingAccess) {
+ $agreementType = if ($script:scanData.Contract -and $script:scanData.Contract[0].AgreementType) { $script:scanData.Contract[0].AgreementType } else { '' }
+ $billingMsg = switch -Wildcard ($agreementType) {
+ 'EnterpriseAgreement' {
+ "[!] No billing account access.`n`nFor EA enrollments, billing data requires an EA role (Enterprise Administrator, EA Reader, or Department Reader) assigned at the enrollment level. Standard subscription RBAC roles (Reader, Contributor, Owner) do not grant access to billing accounts or departments.`n`nTo resolve: In the Azure portal, go to Cost Management + Billing > Billing scopes, select your EA enrollment, and ask an Enterprise Administrator to assign you the EA Reader role."
+ }
+ 'MicrosoftPartnerAgreement' {
+ "[!] No billing account access.`n`nThis subscription is managed by a CSP partner. Billing account access is controlled by the partner organization. Contact your CSP partner to request visibility into billing profiles and invoice sections, or ask them to assign Billing Reader on the billing account in Partner Center."
+ }
+ 'MicrosoftCustomerAgreement' {
+ "[!] No billing account access.`n`nFor MCA accounts, billing data requires a billing role (Billing Account Reader, Billing Profile Reader, or Invoice Section Reader) assigned on the billing account. Standard Azure RBAC roles on subscriptions do not grant billing access.`n`nTo resolve: In the Azure portal, go to Cost Management + Billing > Billing scopes, select your billing account, and assign Billing Account Reader to your user."
+ }
+ default {
+ "[!] No billing account access. Assign Billing Reader on your billing account to see billing profiles, invoice sections, and cost allocation rules."
+ }
+ }
+ $script:BillingAccessNote.Text = $billingMsg
+ return
+ }
+ $script:BillingAccessNote.Text = ''
+
+ # Billing Accounts
+ if ($d.BillingAccounts.Count -gt 0) {
+ $baRows = $d.BillingAccounts | ForEach-Object {
+ [PSCustomObject]@{
+ 'Account Name' = $_.DisplayName
+ 'Agreement Type' = $_.AgreementType
+ 'Account Type' = $_.AccountType
+ 'Status' = $_.AccountStatus
+ }
+ }
+ $script:BillingAccountsGrid.ItemsSource = @($baRows)
+ }
+ else {
+ $script:BillingAccountsGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No billing accounts found.' })
+ }
+
+ # Billing Profiles
+ if ($d.BillingProfiles.Count -gt 0) {
+ $bpRows = $d.BillingProfiles | ForEach-Object {
+ [PSCustomObject]@{
+ 'Profile Name' = $_.DisplayName
+ 'Billing Account' = $_.BillingAccount
+ 'Currency' = $_.Currency
+ 'Invoice Day' = $_.InvoiceDay
+ 'Status' = $_.Status
+ }
+ }
+ $script:BillingProfilesGrid.ItemsSource = @($bpRows)
+ }
+ else {
+ $script:BillingProfilesGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No billing profiles found (MCA/MPA only).' })
+ }
+
+ # Invoice Sections
+ if ($d.InvoiceSections.Count -gt 0) {
+ $isRows = $d.InvoiceSections | ForEach-Object {
+ [PSCustomObject]@{
+ 'Section Name' = $_.DisplayName
+ 'Billing Profile' = $_.BillingProfile
+ 'Billing Account' = $_.BillingAccount
+ 'State' = $_.State
+ }
+ }
+ $script:InvoiceSectionsGrid.ItemsSource = @($isRows)
+ }
+ else {
+ $script:InvoiceSectionsGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No invoice sections found (MCA only).' })
+ }
+
+ # EA Departments
+ if ($d.EADepartments.Count -gt 0) {
+ $script:EADeptHeader.Visibility = 'Visible'
+ $script:EADeptGrid.Visibility = 'Visible'
+ $eaRows = $d.EADepartments | ForEach-Object {
+ [PSCustomObject]@{
+ 'Department' = $_.DisplayName
+ 'Billing Account' = $_.BillingAccount
+ 'Cost Center' = $_.CostCenter
+ 'Status' = $_.Status
+ }
+ }
+ $script:EADeptGrid.ItemsSource = @($eaRows)
+ }
+
+ # Cost Allocation Rules
+ if ($d.CostAllocationRules.Count -gt 0) {
+ $carRows = $d.CostAllocationRules | ForEach-Object {
+ [PSCustomObject]@{
+ 'Rule Name' = $_.RuleName
+ 'Description' = $_.Description
+ 'Status' = $_.Status
+ 'Source Count' = $_.SourceCount
+ 'Target Count' = $_.TargetCount
+ 'Created' = $_.CreatedDate
+ 'Updated' = $_.UpdatedDate
+ }
+ }
+ $script:CostAllocationGrid.ItemsSource = @($carRows)
+ }
+ else {
+ $script:CostAllocationGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No cost allocation rules configured. Cost allocation rules let you redistribute shared costs across subscriptions.' })
+ }
+}
+
+#-----------------------------------------------------------------------
+# BUDGET STATUS POPULATION
+#-----------------------------------------------------------------------
+function Populate-BudgetSection {
+ # BudgetSummaryText / BudgetGrid were removed from the XAML (budget
+ # management lives on the Budgets tab now). Skip silently when the
+ # legacy overview elements no longer exist.
+ if (-not $script:BudgetSummaryText -and -not $script:BudgetGrid) { return }
+
+ $d = $script:scanData
+ if (-not $d.Budgets) {
+ if ($script:BudgetSummaryText) { $script:BudgetSummaryText.Text = 'Budget data not available.' }
+ return
+ }
+
+ $b = $d.Budgets
+ $riskText = "$($b.SubsWithBudget) of $($b.SubsWithBudget + $b.SubsWithoutBudget) subscriptions have budgets ($($b.BudgetCoverage)% coverage)"
+ if ($b.SubsWithoutBudget -gt 0) {
+ $riskText += " | $($b.SubsWithoutBudget) subs have NO budget configured"
+ }
+ if ($script:BudgetSummaryText) { $script:BudgetSummaryText.Text = $riskText }
+
+ if ($script:BudgetGrid) {
+ if ($b.Budgets.Count -gt 0) {
+ $rows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($budget in $b.Budgets) {
+ $sym = Get-CurrencySymbol $budget.Currency
+ [void]$rows.Add([PSCustomObject]@{
+ Subscription = $budget.Subscription
+ 'Budget Name' = $budget.BudgetName
+ Category = $budget.Category
+ 'Budget Amount' = "$sym$(([double]$budget.Amount).ToString('N2'))"
+ 'Actual Spend' = "$sym$(([double]$budget.ActualSpend).ToString('N2'))"
+ '% Used' = "$($budget.PctUsed)%"
+ 'Forecast' = "$sym$(([double]$budget.Forecast).ToString('N2'))"
+ '% Forecast' = "$($budget.PctForecast)%"
+ Risk = $budget.Risk
+ Thresholds = $budget.Thresholds
+ Contacts = if ($budget.ContactEmails) { $budget.ContactEmails } else { '' }
+ })
+ }
+ $script:BudgetGrid.ItemsSource = @($rows | Sort-Object { [double]($_.'% Used' -replace '%', '') } -Descending)
+ }
+ else {
+ $script:BudgetGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No budgets configured. Set up Azure Budgets to track spend against targets.' })
+ }
+ }
+}
+
+#-----------------------------------------------------------------------
+# COST ANOMALY DETECTION (month-over-month per subscription)
+#-----------------------------------------------------------------------
+function Populate-AnomalySection {
+ $d = $script:scanData
+ if (-not $d.CostTrend -or -not $d.CostTrend.HasData) {
+ $script:AnomalyNote.Text = 'Cost trend data not available for anomaly detection.'
+ return
+ }
+
+ # Build per-subscription month-over-month from cost data + trend
+ $anomalies = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $currency = if ($d.CostTrend.Months[0].Currency) { Get-CurrencySymbol -Code $d.CostTrend.Months[0].Currency } else { '$' }
+
+ if ($d.Costs) {
+ $months = $d.CostTrend.Months
+ $lastMonth = if ($months.Count -ge 2) { $months[$months.Count - 2] } else { $null }
+ $currentMonth = $months[$months.Count - 1]
+
+ foreach ($sub in $d.Auth.Subscriptions) {
+ $currentCost = if ($d.Costs.ContainsKey($sub.Id)) { $d.Costs[$sub.Id].Forecast } else { 0 }
+ # Use the ratio of this sub's cost to total to estimate per-sub last month
+ $totalCurrent = 0
+ foreach ($entry in $d.Costs.GetEnumerator()) { $totalCurrent += $entry.Value.Forecast }
+ $subShare = if ($totalCurrent -gt 0) { $currentCost / $totalCurrent } else { 0 }
+
+ if ($lastMonth -and $lastMonth.Cost -gt 0) {
+ $estLastMonth = [math]::Round($lastMonth.Cost * $subShare, 2)
+ if ($estLastMonth -gt 50) {
+ $change = $currentCost - $estLastMonth
+ $changePct = [math]::Round(($change / $estLastMonth) * 100, 1)
+ if ([math]::Abs($changePct) -ge 25) {
+ $direction = if ($changePct -gt 0) { 'Up' } else { 'Down' }
+ [void]$anomalies.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ 'Prior Month (est.)' = "$currency$($estLastMonth.ToString('N2'))"
+ 'Current Forecast' = "$currency$($currentCost.ToString('N2'))"
+ 'Change' = "$currency$($change.ToString('N2'))"
+ 'Change %' = "$changePct%"
+ Direction = $direction
+ })
+ }
+ }
+ }
+ }
+ }
+
+ if ($anomalies.Count -gt 0) {
+ $script:AnomalyNote.Text = "$($anomalies.Count) subscription(s) with 25%+ month-over-month cost change detected."
+ $script:AnomalyGrid.ItemsSource = @($anomalies | Sort-Object { [math]::Abs([double]($_.'Change %' -replace '%', '')) } -Descending)
+ }
+ else {
+ $script:AnomalyNote.Text = 'No significant cost anomalies detected (all subscriptions within 25% of prior month).'
+ $script:AnomalyGrid.ItemsSource = @()
+ }
+}
+
+#-----------------------------------------------------------------------
+# COST MANAGEMENT ALERTS (API-based triggered alerts + configured rules)
+#-----------------------------------------------------------------------
+function Populate-AlertsSection {
+ $d = $script:scanData
+ if (-not $d.AnomalyAlerts -or -not $d.AnomalyAlerts.HasData) {
+ $script:AlertsSummaryNote.Text = 'No Cost Management alerts found.'
+ $script:TriggeredAlertsGrid.ItemsSource = @()
+ $script:ConfiguredRulesGrid.ItemsSource = @()
+ return
+ }
+
+ $aa = $d.AnomalyAlerts
+ $parts = @()
+ if ($aa.TotalAlerts -gt 0) { $parts += "$($aa.TotalAlerts) triggered alert(s)" }
+ if ($aa.ActiveAlertCount -gt 0) { $parts += "$($aa.ActiveAlertCount) active" }
+ if ($aa.AnomalyAlertCount -gt 0) { $parts += "$($aa.AnomalyAlertCount) anomaly" }
+ if ($aa.BudgetAlertCount -gt 0) { $parts += "$($aa.BudgetAlertCount) budget" }
+ if ($aa.ConfiguredRuleCount -gt 0) { $parts += "$($aa.ConfiguredRuleCount) configured rule(s)" }
+ $script:AlertsSummaryNote.Text = if ($parts.Count -gt 0) { $parts -join ' | ' } else { 'No alerts found.' }
+
+ # Triggered alerts grid
+ if ($aa.TriggeredAlerts.Count -gt 0) {
+ $rows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($a in $aa.TriggeredAlerts) {
+ $sym = Get-CurrencySymbol $a.Unit
+ [void]$rows.Add([PSCustomObject]@{
+ Subscription = $a.Subscription
+ Type = $a.AlertType
+ Category = $a.Category
+ Status = $a.Status
+ Amount = "$sym$(([double]$a.Amount).ToString('N2'))"
+ 'Current Spend' = "$sym$(([double]$a.CurrentSpend).ToString('N2'))"
+ Contacts = $a.Contacts
+ Created = $a.CreatedAt
+ })
+ }
+ $script:TriggeredAlertsGrid.ItemsSource = @($rows | Sort-Object Created -Descending)
+ }
+ else {
+ $script:TriggeredAlertsGrid.ItemsSource = @()
+ }
+
+ # Configured anomaly rules grid
+ if ($aa.ConfiguredRules.Count -gt 0) {
+ $rows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($r in $aa.ConfiguredRules) {
+ [void]$rows.Add([PSCustomObject]@{
+ Subscription = $r.Subscription
+ 'Rule Name' = $r.DisplayName
+ Status = $r.Status
+ Recipients = $r.ToEmails
+ 'Next Run' = $r.NextRunTime
+ })
+ }
+ $script:ConfiguredRulesGrid.ItemsSource = @($rows)
+ }
+ else {
+ $script:ConfiguredRulesGrid.ItemsSource = @()
+ }
+}
+
+#-----------------------------------------------------------------------
+# COMMITMENT UTILIZATION POPULATION
+#-----------------------------------------------------------------------
+function Populate-CommitmentSection {
+ $d = $script:scanData
+
+ # RI Util card
+ if ($d.Commitments) {
+ $riAvg = $d.Commitments.RIAvgUtilization
+ $script:RIUtilText.Text = if ($riAvg -ge 0) { "$riAvg%" } else { 'N/A' }
+ $riCount = $d.Commitments.Reservations.Count
+ $spCount = $d.Commitments.SavingsPlans.Count
+ $underutil = $d.Commitments.UnderutilizedRIs
+ $detailParts = @()
+ if ($riCount -gt 0) { $detailParts += "$riCount RIs" }
+ if ($spCount -gt 0) { $detailParts += "$spCount SPs" }
+ if ($underutil -gt 0) { $detailParts += "$underutil underutilized" }
+ $script:RIUtilDetail.Text = if ($detailParts.Count -gt 0) { $detailParts -join ' | ' } else { 'No existing commitments found' }
+
+ # Commitment grid - combine RIs and SPs
+ $commitRows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($ri in $d.Commitments.Reservations) {
+ [void]$commitRows.Add([PSCustomObject]@{
+ Type = 'Reservation'
+ Name = $ri.Name
+ 'Resource Type' = $ri.ResourceType
+ Quantity = $ri.Quantity
+ 'Utilization %' = "$($ri.UtilizationPercent)%"
+ Status = $ri.Status
+ })
+ }
+ foreach ($sp in $d.Commitments.SavingsPlans) {
+ [void]$commitRows.Add([PSCustomObject]@{
+ Type = 'Savings Plan'
+ Name = $sp.Name
+ 'Resource Type' = $sp.BenefitType
+ Quantity = '-'
+ 'Utilization %' = "$($sp.UtilizationPercent)%"
+ Status = $sp.Status
+ })
+ }
+ if ($commitRows.Count -gt 0) {
+ $script:CommitmentGrid.ItemsSource = @($commitRows)
+ }
+ else {
+ $script:CommitmentGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No active reservations or savings plans found.' })
+ }
+ }
+ else {
+ $script:RIUtilText.Text = 'N/A'
+ $script:RIUtilDetail.Text = 'Could not query commitment data'
+ $script:CommitmentGrid.ItemsSource = @([PSCustomObject]@{ Status = 'Commitment utilization data not available.' })
+ }
+}
+
+#-----------------------------------------------------------------------
+# ORPHANED RESOURCES POPULATION
+#-----------------------------------------------------------------------
+function Populate-OrphanedSection {
+ $d = $script:scanData
+
+ # Map orphan categories to ARM resource types for cost lookup
+ $categoryToType = @{
+ 'Orphaned Disk' = 'microsoft.compute/disks'
+ 'Unattached Public IP' = 'microsoft.network/publicipaddresses'
+ 'Unattached NIC' = 'microsoft.network/networkinterfaces'
+ 'Deallocated VM' = 'microsoft.compute/virtualmachines'
+ 'Empty App Service Plan' = 'microsoft.web/serverfarms'
+ 'Old Snapshot' = 'microsoft.compute/snapshots'
+ }
+
+ # Currency helper
+ $currency = if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) {
+ Get-CurrencySymbol -Code $d.ResourceCosts[0].Currency
+ }
+ else { '$' }
+
+ if ($d.Orphans -and $d.Orphans.Orphans.Count -gt 0) {
+ $orphans = $d.Orphans.Orphans
+ $script:OrphanCountText.Text = "$($orphans.Count) found"
+
+ # Summarize by category
+ $byCat = $orphans | Group-Object Category
+ $catParts = $byCat | ForEach-Object { "$($_.Count) $($_.Name)" }
+ $script:OrphanDetailText.Text = ($catParts -join ', ')
+
+ $orphanRows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $totalWaste = 0.0
+ $costedCount = 0
+
+ foreach ($o in $orphans) {
+ $rc = $null
+ $armType = $categoryToType[$o.Category]
+ if ($armType -and $d.ResourceCosts) {
+ $rc = Find-ResourceCost -Name $o.ResourceName -SubscriptionId $o.SubscriptionId -ResourceGroup $o.ResourceGroup -ResourceType $armType
+ }
+ $mtdCost = if ($rc -and $rc.Actual) { $rc.Actual } else { $null }
+ $annualEst = if ($mtdCost -and $mtdCost -gt 0) {
+ $dayOfMonth = (Get-Date).Day
+ $daysInMonth = [DateTime]::DaysInMonth((Get-Date).Year, (Get-Date).Month)
+ $projectedMonthly = $mtdCost / $dayOfMonth * $daysInMonth
+ [math]::Round($projectedMonthly * 12, 2)
+ }
+ else { $null }
+
+ if ($mtdCost -and $mtdCost -gt 0) {
+ $totalWaste += $mtdCost
+ $costedCount++
+ }
+
+ [void]$orphanRows.Add([PSCustomObject]@{
+ Category = $o.Category
+ Resource = $o.ResourceName
+ 'Resource Group' = $o.ResourceGroup
+ Location = $o.Location
+ Detail = $o.Detail
+ 'Cost (MTD)' = if ($mtdCost) { "$currency$($mtdCost.ToString('N2'))" } else { '-' }
+ 'Est. Annual' = if ($annualEst) { "$currency$($annualEst.ToString('N2'))" } else { '-' }
+ })
+ }
+ $script:OrphanGrid.ItemsSource = @($orphanRows)
+
+ # Summary with dollar amounts
+ $summary = "$($orphans.Count) orphaned/idle resources found across $($byCat.Count) categories."
+ if ($costedCount -gt 0) {
+ $annualTotal = 0.0
+ $dayOfMonth = (Get-Date).Day
+ $daysInMonth = [DateTime]::DaysInMonth((Get-Date).Year, (Get-Date).Month)
+ $annualTotal = [math]::Round(($totalWaste / $dayOfMonth * $daysInMonth) * 12, 2)
+ $summary += " Estimated waste: $currency$($totalWaste.ToString('N2')) MTD ($currency$($annualTotal.ToString('N2'))/yr projected) across $costedCount costed resources."
+ }
+ $uncosted = $orphans.Count - $costedCount
+ if ($uncosted -gt 0) {
+ $summary += " $uncosted resources had no cost data (may be zero-cost or recently created)."
+ }
+ $script:OrphanSummaryText.Text = $summary
+ }
+ else {
+ $script:OrphanCountText.Text = '0'
+ $script:OrphanDetailText.Text = 'No orphaned resources'
+ $script:OrphanSummaryText.Text = 'No orphaned or idle resources detected. Environment looks clean.'
+ $script:OrphanGrid.ItemsSource = @([PSCustomObject]@{ Status = 'No orphaned resources found. All disks, IPs, NICs, VMs, and App Service Plans appear to be in use.' })
+ }
+}
+
+#-----------------------------------------------------------------------
+# IDLE VM SECTION (Optimization tab)
+#-----------------------------------------------------------------------
+function Populate-IdleVMSection {
+ $d = $script:scanData
+ if (-not $d.IdleVMs -or -not $d.IdleVMs.HasData) {
+ $script:IdleVMSummaryText.Text = "No idle or underutilized VMs detected (scanned $($d.IdleVMs.ScannedVMs) running VMs)."
+ $script:IdleVMGrid.ItemsSource = @([PSCustomObject]@{ Status = 'All running VMs show healthy utilization. No action needed.' })
+ return
+ }
+
+ if (-not $script:resCostMapBuilt) { Build-ResourceCostMap }
+ $currency = if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) {
+ Get-CurrencySymbol -Code $d.ResourceCosts[0].Currency
+ }
+ else { '$' }
+
+ $idleCount = ($d.IdleVMs.IdleVMs | Where-Object { $_.Classification -eq 'Idle' }).Count
+ $underCount = ($d.IdleVMs.IdleVMs | Where-Object { $_.Classification -eq 'Underutilized' }).Count
+ $script:IdleVMSummaryText.Text = "$($d.IdleVMs.Count) VM(s) flagged: $idleCount idle, $underCount underutilized (of $($d.IdleVMs.ScannedVMs) running VMs scanned)"
+
+ $rows = @()
+ foreach ($vm in $d.IdleVMs.IdleVMs) {
+ $rc = Find-ResourceCost -Name $vm.VMName -SubscriptionId $vm.SubscriptionId -ResourceGroup $vm.ResourceGroup -ResourceType 'microsoft.compute/virtualmachines'
+ $actual = if ($rc) { "$currency$($rc.Actual.ToString('N2'))" } else { '-' }
+ $forecast = if ($rc) { "$currency$($rc.Forecast.ToString('N2'))" } else { '-' }
+ $rows += [PSCustomObject]@{
+ Classification = $vm.Classification
+ VM = $vm.VMName
+ 'Resource Group' = $vm.ResourceGroup
+ Size = $vm.VMSize
+ OS = $vm.OS
+ 'Avg CPU (14d)' = "$($vm.AvgCPU14d)%"
+ 'Net/Day' = $vm.NetworkPerDay
+ 'Cost (MTD)' = $actual
+ Forecast = $forecast
+ Recommendation = $vm.Recommendation
+ }
+ }
+ $script:IdleVMGrid.ItemsSource = @($rows)
+}
+
+#-----------------------------------------------------------------------
+# STORAGE TIER SECTION (Optimization tab)
+#-----------------------------------------------------------------------
+function Populate-StorageTierSection {
+ $d = $script:scanData
+ if (-not $d.StorageTier -or -not $d.StorageTier.HasData) {
+ $total = if ($d.StorageTier) { $d.StorageTier.TotalHotAccounts } else { 0 }
+ $script:StorageTierSummaryText.Text = "No storage tier optimization found ($total hot-tier accounts scanned)."
+ $script:StorageTierGrid.ItemsSource = @([PSCustomObject]@{ Status = 'All hot-tier storage accounts show healthy transaction activity. No action needed.' })
+ return
+ }
+
+ $archiveCount = ($d.StorageTier.Recommendations | Where-Object { $_.Recommendation -eq 'Archive' }).Count
+ $coolCount = ($d.StorageTier.Recommendations | Where-Object { $_.Recommendation -eq 'Cool' }).Count
+ $script:StorageTierSummaryText.Text = "$($d.StorageTier.Count) account(s) flagged: $archiveCount for Archive, $coolCount for Cool (of $($d.StorageTier.TotalHotAccounts) hot-tier accounts)"
+
+ $rows = @()
+ foreach ($sa in $d.StorageTier.Recommendations) {
+ $rows += [PSCustomObject]@{
+ 'Storage Account' = $sa.StorageAccount
+ 'Resource Group' = $sa.ResourceGroup
+ Location = $sa.Location
+ SKU = $sa.SKU
+ 'Current Tier' = $sa.CurrentTier
+ 'Capacity (GB)' = $sa.CapacityGB
+ 'Transactions (30d)' = $sa.Transactions30d
+ Recommendation = $sa.Recommendation
+ 'Est. Savings' = "$($sa.EstSavingsPct)%"
+ }
+ }
+ $script:StorageTierGrid.ItemsSource = @($rows)
+}
+
+#-----------------------------------------------------------------------
+# RESOURCES TAB (static links — no scan data needed)
+#-----------------------------------------------------------------------
+function Populate-ResourcesTab {
+ # Helper to create a clickable hyperlink block
+ function New-LinkBlock {
+ param([string]$Text, [string]$Url, [string]$Description)
+ $panel = [System.Windows.Controls.StackPanel]::new()
+ $panel.Margin = [System.Windows.Thickness]::new(0, 2, 0, 6)
+
+ $link = [System.Windows.Documents.Hyperlink]::new()
+ $link.Inlines.Add($Text)
+ $link.NavigateUri = [Uri]::new($Url)
+ $link.Add_RequestNavigate({ Start-Process $_.Uri.AbsoluteUri })
+
+ $tb = [System.Windows.Controls.TextBlock]::new()
+ $tb.FontSize = 13
+ $tb.Inlines.Add($link)
+ $panel.Children.Add($tb) | Out-Null
+
+ if ($Description) {
+ $desc = [System.Windows.Controls.TextBlock]::new()
+ $desc.Text = $Description
+ $desc.FontSize = 11
+ $desc.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#666')
+ $desc.TextWrapping = [System.Windows.TextWrapping]::Wrap
+ $desc.Margin = [System.Windows.Thickness]::new(12, 0, 0, 0)
+ $panel.Children.Add($desc) | Out-Null
+ }
+ $panel
+ }
+
+ # FinOps Framework
+ $script:ResourcesFinOpsPanel.Children.Clear()
+ $finopsLinks = @(
+ , @('FinOps Foundation', 'https://www.finops.org/', 'The FinOps Foundation — framework, community, certifications.')
+ , @('FinOps with Azure', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/finops/', 'Microsoft Learn — FinOps principles applied to Azure.')
+ , @('Cloud Adoption Framework — Cost Management', 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/manage/azure-server-management/cost-management', 'CAF discipline for managing cloud costs at enterprise scale.')
+ , @('FinOps Toolkit (GitHub)', 'https://github.com/microsoft/finops-toolkit', 'Open-source Power BI reports, workbooks, and Bicep modules from Microsoft.')
+ )
+ foreach ($item in $finopsLinks) {
+ $script:ResourcesFinOpsPanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null
+ }
+
+ # Cost Management
+ $script:ResourcesCostPanel.Children.Clear()
+ $costLinks = @(
+ , @('Azure Cost Management Overview', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/overview-cost-management', 'Core service for analyzing, monitoring, and optimizing Azure costs.')
+ , @('Azure Advisor — Cost Recommendations', 'https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations', 'Automated right-sizing, shutdown, and purchase recommendations.')
+ , @('Azure Pricing Calculator', 'https://azure.microsoft.com/en-us/pricing/calculator/', 'Estimate costs before deploying resources.')
+ , @('Cost Management Best Practices', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/cost-mgt-best-practices', 'Official best practices for Azure cost management.')
+ )
+ foreach ($item in $costLinks) {
+ $script:ResourcesCostPanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null
+ }
+
+ # Rate Optimization
+ $script:ResourcesRatePanel.Children.Clear()
+ $rateLinks = @(
+ , @('Azure Reservations', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/save-compute-costs-reservations', 'Lock in discounted rates for VMs, SQL, Cosmos, and more (30-72% savings).')
+ , @('Azure Savings Plans', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/savings-plan/', 'Flexible hourly commitment across compute services (15-65% savings).')
+ , @('Azure Hybrid Benefit', 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/hybrid-use-benefit-licensing', 'Use existing Windows/SQL licenses to save 40-85% on Azure VMs and SQL.')
+ , @('Dev/Test Pricing', 'https://azure.microsoft.com/en-us/pricing/dev-test/', 'Discounted rates for dev/test workloads — no Windows license charges.')
+ )
+ foreach ($item in $rateLinks) {
+ $script:ResourcesRatePanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null
+ }
+
+ # Governance
+ $script:ResourcesGovernancePanel.Children.Clear()
+ $govLinks = @(
+ , @('Azure Policy Overview', 'https://learn.microsoft.com/en-us/azure/governance/policy/overview', 'Enforce organizational standards and assess compliance at scale.')
+ , @('Tagging Strategy', 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging', 'CAF tagging best practices for cost allocation and governance.')
+ , @('Management Group Hierarchy', 'https://learn.microsoft.com/en-us/azure/governance/management-groups/overview', 'Organize subscriptions and apply policies at scale.')
+ , @('Azure Budgets', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/tutorial-acm-create-budgets', 'Set spending thresholds and receive alerts when costs exceed targets.')
+ )
+ foreach ($item in $govLinks) {
+ $script:ResourcesGovernancePanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null
+ }
+
+ # Workbooks & Tools
+ $script:ResourcesToolsPanel.Children.Clear()
+ $toolLinks = @(
+ , @('Orphaned Resources Workbook', 'https://github.com/dolevshor/azure-orphan-resources', 'Community Azure Workbook showing orphaned resources across subscriptions.')
+ , @('Azure Optimization Engine (AOE)', 'https://github.com/helderpinto/AzureOptimizationEngine', 'Automated optimization recommendations engine using Log Analytics.')
+ , @('Cost Management Labs', 'https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/quick-acm-cost-analysis', 'Hands-on quickstart: analyze costs in the Azure portal.')
+ , @('Azure Charts', 'https://azurecharts.com/', 'Visual changelog of Azure services, regions, and updates.')
+ , @('Azure FinOps Multitool (this app)', 'https://github.com/z-larsen/Azure-FinOps-Multitool', 'Source code and documentation for this scanner.')
+ )
+ foreach ($item in $toolLinks) {
+ $script:ResourcesToolsPanel.Children.Add((New-LinkBlock -Text $item[0] -Url $item[1] -Description $item[2])) | Out-Null
+ }
+}
+
+#-----------------------------------------------------------------------
+# BUDGETS TAB
+#-----------------------------------------------------------------------
+function Populate-BudgetsTab {
+ $d = $script:scanData
+ if (-not $d.Auth -or -not $d.Auth.Subscriptions) { return }
+
+ # Populate subscription dropdown (for viewing budgets)
+ $script:BudgetSubSelector.Items.Clear()
+ $script:BudgetSubSelector.Items.Add('All Subscriptions') | Out-Null
+ foreach ($sub in $d.Auth.Subscriptions) {
+ $script:BudgetSubSelector.Items.Add($sub.Name) | Out-Null
+ }
+ $script:BudgetSubSelector.SelectedIndex = 0
+
+ # Populate budget deploy scope selector with actual subscriptions
+ $script:BudgetDeployScopeSelector.Items.Clear()
+ $allItem = [System.Windows.Controls.ComboBoxItem]::new()
+ $allItem.Content = 'All Subscriptions'
+ $script:BudgetDeployScopeSelector.Items.Add($allItem) | Out-Null
+ foreach ($sub in $d.Auth.Subscriptions) {
+ $item = [System.Windows.Controls.ComboBoxItem]::new()
+ $item.Content = $sub.Name
+ $item.Tag = $sub.Id
+ $script:BudgetDeployScopeSelector.Items.Add($item) | Out-Null
+ }
+ $script:BudgetDeployScopeSelector.SelectedIndex = 0
+
+ # Populate Action Group selector
+ $script:BudgetActionGroupSelector.Items.Clear()
+ $noneItem = [System.Windows.Controls.ComboBoxItem]::new()
+ $noneItem.Content = '(None)'
+ $noneItem.Tag = ''
+ $script:BudgetActionGroupSelector.Items.Add($noneItem) | Out-Null
+ foreach ($sub in $d.Auth.Subscriptions) {
+ try {
+ $agPath = "/subscriptions/$($sub.Id)/providers/microsoft.insights/actionGroups?api-version=2023-01-01"
+ $agResp = Invoke-AzRestMethodWithRetry -Path $agPath -Method GET
+ if ($agResp.StatusCode -eq 200) {
+ $ags = ($agResp.Content | ConvertFrom-Json).value
+ foreach ($ag in $ags) {
+ $agItem = [System.Windows.Controls.ComboBoxItem]::new()
+ $agItem.Content = "$($ag.name) ($($sub.Name))"
+ $agItem.Tag = $ag.id
+ $script:BudgetActionGroupSelector.Items.Add($agItem) | Out-Null
+ }
+ }
+ }
+ catch {
+ Write-Warning "Could not list action groups for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ $script:BudgetActionGroupSelector.SelectedIndex = 0
+
+ # Populate tag name dropdown for tag-scoped budgets
+ $script:BudgetDeployTagNameSelector.Items.Clear()
+ $noneTagItem = [System.Windows.Controls.ComboBoxItem]::new()
+ $noneTagItem.Content = '(No tag filter)'
+ $script:BudgetDeployTagNameSelector.Items.Add($noneTagItem) | Out-Null
+ if ($d.Tags -and $d.Tags.TagNames) {
+ foreach ($tagEntry in $d.Tags.TagNames.GetEnumerator()) {
+ $tagItem = [System.Windows.Controls.ComboBoxItem]::new()
+ $tagItem.Content = "$($tagEntry.Key) ($($tagEntry.Value.ResourceCount) resources)"
+ $tagItem.Tag = $tagEntry.Key
+ $script:BudgetDeployTagNameSelector.Items.Add($tagItem) | Out-Null
+ }
+ }
+ $script:BudgetDeployTagNameSelector.SelectedIndex = 0
+
+ # Populate budget policy scope selector
+ $script:BudgetPolicyScopeSelector.Items.Clear()
+ foreach ($sub in $d.Auth.Subscriptions) {
+ $script:BudgetPolicyScopeSelector.Items.Add("[Sub] $($sub.Name)") | Out-Null
+ }
+ if ($d.Auth.Subscriptions.Count -gt 0) {
+ $script:BudgetPolicyScopeSelector.SelectedIndex = 0
+ }
+}
+
+function Update-BudgetDetailView {
+ $d = $script:scanData
+ $selectedName = $script:BudgetSubSelector.SelectedItem
+ if (-not $selectedName -or -not $d.Budgets) {
+ $script:BudgetSubSummary.Text = 'No budget data available. Run a scan first.'
+ return
+ }
+
+ $budgets = $d.Budgets.Budgets
+ if ($selectedName -ne 'All Subscriptions') {
+ $budgets = @($budgets | Where-Object { $_.Subscription -eq $selectedName })
+ }
+
+ if ($budgets.Count -gt 0) {
+ $overBudget = @($budgets | Where-Object { $_.Risk -eq 'Over Budget' }).Count
+ $atRisk = @($budgets | Where-Object { $_.Risk -eq 'At Risk' }).Count
+ $script:BudgetSubSummary.Text = "$($budgets.Count) budget(s) found. $overBudget over budget, $atRisk at risk."
+
+ $rows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($b in $budgets) {
+ $sym = Get-CurrencySymbol $b.Currency
+ [void]$rows.Add([PSCustomObject]@{
+ Subscription = $b.Subscription
+ 'Budget Name' = $b.BudgetName
+ Category = $b.Category
+ 'Amount' = "$sym$(([double]$b.Amount).ToString('N2'))"
+ 'Actual Spend' = "$sym$(([double]$b.ActualSpend).ToString('N2'))"
+ '% Used' = "$($b.PctUsed)%"
+ 'Forecast' = "$sym$(([double]$b.Forecast).ToString('N2'))"
+ '% Forecast' = "$($b.PctForecast)%"
+ 'Risk' = $b.Risk
+ 'Tag Filter' = if ($b.TagFilter) { $b.TagFilter } else { '' }
+ 'Time Grain' = $b.TimeGrain
+ 'Thresholds' = $b.Thresholds
+ 'Contacts' = if ($b.ContactEmails) { $b.ContactEmails } else { '' }
+ })
+ }
+ $script:BudgetDetailGrid.ItemsSource = @($rows | Sort-Object { [double]($_.'% Used' -replace '[^0-9.]', '') } -Descending)
+ }
+ else {
+ if ($selectedName -eq 'All Subscriptions') {
+ $script:BudgetSubSummary.Text = "No budgets configured on any subscription. Use the section below to deploy one."
+ }
+ else {
+ $script:BudgetSubSummary.Text = "No budget configured on '$selectedName'. Use the section below to deploy one."
+ }
+ $script:BudgetDetailGrid.ItemsSource = @()
+ }
+}
+
+function Deploy-BudgetFromTab {
+ $d = $script:scanData
+ $scope = $script:BudgetDeployScopeSelector.SelectedItem.Content
+ $scopeSubId = $script:BudgetDeployScopeSelector.SelectedItem.Tag
+ $budgetName = $script:BudgetDeployNameInput.Text.Trim()
+ $amountText = $script:BudgetDeployAmountInput.Text.Trim()
+ $timeGrain = $script:BudgetDeployGrainSelector.SelectedItem.Content
+ $emails = $script:BudgetDeployEmailInput.Text.Trim()
+
+ # Get selected action group
+ $actionGroupId = ''
+ if ($script:BudgetActionGroupSelector.SelectedItem -and $script:BudgetActionGroupSelector.SelectedItem.Tag) {
+ $actionGroupId = $script:BudgetActionGroupSelector.SelectedItem.Tag
+ }
+
+ if (-not $budgetName) {
+ $script:BudgetDeployStatus.Foreground = '#D83B01'
+ $script:BudgetDeployStatus.Text = 'Budget name is required.'
+ return
+ }
+ if (-not $amountText -or -not [double]::TryParse($amountText, [ref]$null)) {
+ $script:BudgetDeployStatus.Foreground = '#D83B01'
+ $script:BudgetDeployStatus.Text = 'Amount must be a valid number.'
+ return
+ }
+ $amount = [int][double]$amountText
+
+ # Collect user-defined thresholds (up to 4)
+ $thresholds = @()
+ $thresholdControls = @(
+ @{ Value = $script:BudgetThreshold1; Type = $script:BudgetThreshold1Type },
+ @{ Value = $script:BudgetThreshold2; Type = $script:BudgetThreshold2Type },
+ @{ Value = $script:BudgetThreshold3; Type = $script:BudgetThreshold3Type },
+ @{ Value = $script:BudgetThreshold4; Type = $script:BudgetThreshold4Type }
+ )
+ foreach ($tc in $thresholdControls) {
+ $val = $tc.Value.Text.Trim()
+ if ($val -and [double]::TryParse($val, [ref]$null)) {
+ $pct = [double]$val
+ $thresholdType = if ($tc.Type.SelectedItem) { $tc.Type.SelectedItem.Content } else { 'Actual' }
+ $thresholds += @{ Threshold = $pct; ThresholdType = $thresholdType }
+ }
+ }
+
+ if ($thresholds.Count -eq 0) {
+ $script:BudgetDeployStatus.Foreground = '#D83B01'
+ $script:BudgetDeployStatus.Text = 'At least one threshold is required.'
+ return
+ }
+
+ $startDate = (Get-Date -Day 1).ToString('yyyy-MM-01')
+ $endDate = (Get-Date -Day 1).AddYears(1).ToString('yyyy-MM-01')
+
+ $contactEmails = @()
+ if ($emails) { $contactEmails = @($emails -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) }
+ $contactRoles = @('Owner', 'Contributor')
+
+ # Build notifications from user thresholds
+ $notifications = @{}
+ for ($i = 0; $i -lt $thresholds.Count; $i++) {
+ $t = $thresholds[$i]
+ $notif = @{
+ enabled = $true
+ operator = 'GreaterThan'
+ threshold = $t.Threshold
+ thresholdType = $t.ThresholdType
+ contactEmails = $contactEmails
+ contactRoles = $contactRoles
+ }
+ if ($actionGroupId) {
+ $notif['contactGroups'] = @($actionGroupId)
+ }
+ $notifications["NotificationForExceededBudget$($i + 1)"] = $notif
+ }
+
+ # Get tag filter values
+ $tagFilterName = ''
+ $tagFilterValue = ''
+ if ($script:BudgetDeployTagNameSelector.SelectedItem -and $script:BudgetDeployTagNameSelector.SelectedItem.Tag) {
+ $tagFilterName = $script:BudgetDeployTagNameSelector.SelectedItem.Tag
+ $tagFilterValue = $script:BudgetDeployTagValueInput.Text.Trim()
+ if ($tagFilterName -and -not $tagFilterValue) {
+ $script:BudgetDeployStatus.Foreground = '#D83B01'
+ $script:BudgetDeployStatus.Text = 'Tag value is required when a tag name is selected.'
+ return
+ }
+ }
+
+ $script:BudgetDeployButton.IsEnabled = $false
+ $tagNote = if ($tagFilterName -and $tagFilterValue) { " (filtered by $tagFilterName=$tagFilterValue)" } else { '' }
+ $script:BudgetDeployStatus.Foreground = '#0078D4'
+ $script:BudgetDeployStatus.Text = "Deploying budget '$budgetName'$tagNote..."
+
+ # Force UI update
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [action] {}, [System.Windows.Threading.DispatcherPriority]::Background
+ )
+
+ $successCount = 0
+ $failCount = 0
+ $targetSubs = @()
+
+ if ($scope -eq 'All Subscriptions') {
+ $targetSubs = $d.Auth.Subscriptions
+ }
+ else {
+ # Specific subscription selected
+ $targetSubs = @($d.Auth.Subscriptions | Where-Object { $_.Id -eq $scopeSubId })
+ if ($targetSubs.Count -eq 0) {
+ $targetSubs = @($d.Auth.Subscriptions | Where-Object { $_.Name -eq $scope })
+ }
+ }
+
+ foreach ($sub in $targetSubs) {
+ try {
+ $budgetProps = @{
+ category = 'Cost'
+ amount = $amount
+ timeGrain = $timeGrain
+ timePeriod = @{ startDate = $startDate; endDate = $endDate }
+ notifications = $notifications
+ }
+
+ # Add tag filter if specified
+ if ($tagFilterName -and $tagFilterValue) {
+ $budgetProps.filter = @{
+ tags = @{
+ $tagFilterName = @{
+ name = $tagFilterName
+ operator = 'In'
+ values = @($tagFilterValue)
+ }
+ }
+ }
+ }
+
+ $budgetBody = @{ properties = $budgetProps } | ConvertTo-Json -Depth 10
+
+ $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets/$($budgetName)?api-version=2023-05-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method PUT -Payload $budgetBody
+
+ if ($resp.StatusCode -in @(200, 201)) {
+ $successCount++
+ }
+ else {
+ $failCount++
+ Write-Warning "Budget deploy failed on $($sub.Name): $($resp.StatusCode) $($resp.Content)"
+ }
+ }
+ catch {
+ $failCount++
+ Write-Warning "Budget deploy error on $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+
+ $script:BudgetDeployButton.IsEnabled = $true
+ if ($failCount -eq 0) {
+ $script:BudgetDeployStatus.Foreground = '#107C10'
+ $script:BudgetDeployStatus.Text = "Successfully deployed budget '$budgetName' to $successCount subscription(s) with $($thresholds.Count) threshold(s).$tagNote"
+ }
+ else {
+ $script:BudgetDeployStatus.Foreground = '#D83B01'
+ $script:BudgetDeployStatus.Text = "Deployed to $successCount sub(s), $failCount failed. Check console for details."
+ }
+}
+
+function Deploy-BudgetPolicyFromTab {
+ $d = $script:scanData
+ $effect = if ($script:BudgetPolicyEffectSelector.SelectedItem) { $script:BudgetPolicyEffectSelector.SelectedItem.Content } else { 'AuditIfNotExists' }
+ $selectedIdx = $script:BudgetPolicyScopeSelector.SelectedIndex
+
+ if ($selectedIdx -lt 0 -or $selectedIdx -ge $d.Auth.Subscriptions.Count) {
+ $script:BudgetPolicyStatus.Foreground = '#D83B01'
+ $script:BudgetPolicyStatus.Text = 'Please select a scope.'
+ return
+ }
+
+ $sub = $d.Auth.Subscriptions[$selectedIdx]
+ $scope = "/subscriptions/$($sub.Id)"
+
+ # Built-in policy: "Budgets should be configured on subscriptions"
+ $policyDefId = '/providers/Microsoft.Authorization/policyDefinitions/b60f1662-afbe-4583-8543-26c9e20fa0ca'
+
+ $script:BudgetPolicyDeployButton.IsEnabled = $false
+ $script:BudgetPolicyStatus.Foreground = '#0078D4'
+ $script:BudgetPolicyStatus.Text = "Deploying budget policy ($effect)..."
+
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [System.Windows.Threading.DispatcherPriority]::Render, [action] {})
+
+ try {
+ $result = Deploy-PolicyAssignment -Scope $scope -PolicyDefinitionId $policyDefId `
+ -Effect $effect -DisplayName "Budget Policy ($effect)"
+ if ($result.Success) {
+ $script:BudgetPolicyStatus.Foreground = '#107C10'
+ $script:BudgetPolicyStatus.Text = "Budget policy deployed ($effect) to $($sub.Name)."
+ }
+ else {
+ $script:BudgetPolicyStatus.Foreground = '#D83B01'
+ $script:BudgetPolicyStatus.Text = "Failed: $($result.Message)"
+ }
+ }
+ catch {
+ $script:BudgetPolicyStatus.Foreground = '#D83B01'
+ $script:BudgetPolicyStatus.Text = "Error: $($_.Exception.Message)"
+ }
+ $script:BudgetPolicyDeployButton.IsEnabled = $true
+}
+
+function Start-PolicyRemediation {
+ param(
+ [Parameter(Mandatory)][string]$Scope,
+ [Parameter(Mandatory)][string]$PolicyAssignmentId
+ )
+
+ Write-Host " Creating remediation task for assignment: $PolicyAssignmentId" -ForegroundColor Cyan
+
+ $remediationName = "remediate-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
+ $body = @{
+ properties = @{
+ policyAssignmentId = $PolicyAssignmentId
+ }
+ } | ConvertTo-Json -Depth 5
+
+ $remediationPath = "$Scope/providers/Microsoft.PolicyInsights/remediations/$($remediationName)?api-version=2021-10-01"
+
+ try {
+ $resp = Invoke-AzRestMethodWithRetry -Path $remediationPath -Method PUT -Payload $body
+ if ($resp.StatusCode -in @(200, 201)) {
+ Write-Host " Remediation task '$remediationName' created." -ForegroundColor Green
+ return [PSCustomObject]@{ Success = $true; Message = "Remediation task '$remediationName' created. Check Policy > Remediation in the portal for progress."; Name = $remediationName }
+ }
+ else {
+ $errBody = ($resp.Content | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($resp.StatusCode)" }
+ return [PSCustomObject]@{ Success = $false; Message = $errMsg }
+ }
+ }
+ catch {
+ return [PSCustomObject]@{ Success = $false; Message = $_.Exception.Message }
+ }
+}
+
+#-----------------------------------------------------------------------
+# SUBSCRIPTION SCORECARD
+#-----------------------------------------------------------------------
+function Populate-Scorecard {
+ $d = $script:scanData
+ if (-not $d.Auth -or -not $d.Auth.Subscriptions) { return }
+
+ $rows = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($sub in $d.Auth.Subscriptions) {
+ # Cost info
+ $costActual = 0; $costForecast = 0; $currency = 'USD'
+ if ($d.Costs -and $d.Costs.ContainsKey($sub.Id)) {
+ $c = $d.Costs[$sub.Id]
+ $costActual = $c.Actual
+ $costForecast = $c.Forecast
+ $currency = $c.Currency
+ }
+ $sym = Get-CurrencySymbol $currency
+
+ # Tag compliance
+ $tagScore = 'N/A'
+ if ($d.Tags -and $d.Tags.PerSubscription -and $d.Tags.PerSubscription.ContainsKey($sub.Id)) {
+ $tagScore = "$($d.Tags.PerSubscription[$sub.Id].Coverage)%"
+ }
+ elseif ($d.Tags) {
+ $tagScore = "$($d.Tags.TagCoverage)%"
+ }
+
+ # Optimization count
+ $optCount = 0
+ if ($d.Optimization -and $d.Optimization.Recommendations) {
+ $optCount += @($d.Optimization.Recommendations | Where-Object { $_.SubscriptionId -eq $sub.Id }).Count
+ }
+
+ # Orphan count
+ $orphanCount = 0
+ $orphanSavings = 0.0
+ if ($d.Orphans -and $d.Orphans.Orphans) {
+ $subOrphans = @($d.Orphans.Orphans | Where-Object { $_.SubscriptionId -eq $sub.Id })
+ $orphanCount = $subOrphans.Count
+ # Estimate monthly savings per orphan category (conservative Azure pricing)
+ foreach ($o in $subOrphans) {
+ $orphanSavings += switch ($o.Category) {
+ 'Orphaned Disk' {
+ # Estimate based on disk size from Detail field
+ $diskGb = 0
+ if ($o.Detail -match '(\d+)\s*GB') { $diskGb = [int]$Matches[1] }
+ if ($o.Detail -match 'Premium') { $diskGb * 0.12 } # ~$0.12/GB/mo Premium SSD
+ elseif ($o.Detail -match 'Standard_SSD') { $diskGb * 0.075 }
+ else { $diskGb * 0.04 } # Standard HDD
+ }
+ 'Unattached Public IP' { 3.65 } # ~$0.005/hr static IP
+ 'Unattached NIC' { 0 } # NICs are free but clutter
+ 'Deallocated VM' { 15 } # OS disk + IP costs while deallocated
+ 'Empty App Service Plan' { 55 } # Basic tier ~$55/mo
+ 'Old Snapshot' { 5 } # ~$0.05/GB, typical 100GB
+ default { 5 }
+ }
+ }
+ }
+
+ # Budget risk
+ $budgetRisk = 'No Budget'
+ if ($d.Budgets -and $d.Budgets.Budgets) {
+ $subBudgets = @($d.Budgets.Budgets | Where-Object { $_.SubscriptionId -eq $sub.Id })
+ if ($subBudgets.Count -gt 0) {
+ $worstRisk = ($subBudgets | Sort-Object PercentUsed -Descending | Select-Object -First 1).Risk
+ $budgetRisk = $worstRisk
+ }
+ }
+
+ # Cost trend direction
+ $trendDir = '-'
+ if ($d.CostTrend -and $d.CostTrend.HasData -and $d.CostTrend.Months.Count -ge 2) {
+ $last = $d.CostTrend.Months[$d.CostTrend.Months.Count - 1].Cost
+ $prev = $d.CostTrend.Months[$d.CostTrend.Months.Count - 2].Cost
+ if ($prev -gt 0) {
+ $pct = [math]::Round((($last - $prev) / $prev) * 100, 1)
+ $trendDir = if ($pct -gt 5) { "Up $pct%" } elseif ($pct -lt -5) { "Down $([math]::Abs($pct))%" } else { 'Stable' }
+ }
+ }
+
+ [void]$rows.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ 'Actual (MTD)' = "$sym$($costActual.ToString('N2'))"
+ 'Forecast' = "$sym$($costForecast.ToString('N2'))"
+ 'Tag Coverage' = $tagScore
+ 'Optimizations' = $optCount
+ 'Orphaned' = $orphanCount
+ 'Orphan Savings' = if ($orphanSavings -gt 0) { "$sym$([math]::Round($orphanSavings, 2).ToString('N2'))/mo" } else { '-' }
+ 'Budget Status' = $budgetRisk
+ 'Cost Trend' = $trendDir
+ })
+ }
+
+ $script:ScorecardGrid.ItemsSource = @($rows | Sort-Object { [double]($_.'Actual (MTD)' -replace '[^0-9.]', '') } -Descending)
+}
+
+# -- Subscription Selector Dialog ----------------------------------------
+# Shows a popup with checkboxes for each subscription. Returns only selected subs.
+# Called after tenant connection so users can narrow the scan scope.
+function Show-SubscriptionSelector {
+ param(
+ [Parameter(Mandatory)][object[]]$Subscriptions,
+ [object[]]$SkippedSubs,
+ [System.Windows.Window]$ParentWindow
+ )
+
+ $subCount = $Subscriptions.Count
+ # For small tenants (≤5 subs), skip the selector — just scan everything
+ if ($subCount -le 5) { return $Subscriptions }
+
+ $dlgHeight = [math]::Min(560, 220 + ($subCount * 26))
+
+ $dlgXaml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($dlgXaml))
+ $dlgWin = [System.Windows.Markup.XamlReader]::Load($reader)
+ if ($ParentWindow) { $dlgWin.Owner = $ParentWindow }
+
+ $subListPanel = $dlgWin.FindName('SubListPanel')
+ $selectAllBtn = $dlgWin.FindName('SelectAllBtn')
+ $selectNoneBtn = $dlgWin.FindName('SelectNoneBtn')
+ $countLabel = $dlgWin.FindName('CountLabel')
+ $cancelBtn = $dlgWin.FindName('CancelBtn')
+ $okBtn = $dlgWin.FindName('OkBtn')
+
+ # Build checkbox list
+ $checkboxes = [System.Collections.Generic.List[System.Windows.Controls.CheckBox]]::new()
+ foreach ($sub in ($Subscriptions | Sort-Object Name)) {
+ $cb = [System.Windows.Controls.CheckBox]::new()
+ $cb.Content = "$($sub.Name) ($($sub.Id))"
+ $cb.IsChecked = $true
+ $cb.Tag = $sub
+ $cb.Margin = [System.Windows.Thickness]::new(4, 3, 4, 3)
+ $cb.FontSize = 12
+ [void]$checkboxes.Add($cb)
+ [void]$subListPanel.Children.Add($cb)
+ }
+
+ # Update count label
+ $updateCount = {
+ $sel = ($checkboxes | Where-Object { $_.IsChecked }).Count
+ $countLabel.Text = "$sel of $subCount selected"
+ $okBtn.IsEnabled = ($sel -gt 0)
+ }
+ & $updateCount
+
+ foreach ($cb in $checkboxes) {
+ $cb.Add_Checked($updateCount)
+ $cb.Add_Unchecked($updateCount)
+ }
+
+ # Select All / None
+ $selectAllBtn.Add_Click({ foreach ($c in $checkboxes) { $c.IsChecked = $true } }.GetNewClosure())
+ $selectNoneBtn.Add_Click({ foreach ($c in $checkboxes) { $c.IsChecked = $false } }.GetNewClosure())
+
+ # OK / Cancel
+ $script:_subSelectorResult = $null
+ $okBtn.Add_Click({
+ $script:_subSelectorResult = @($checkboxes | Where-Object { $_.IsChecked } | ForEach-Object { $_.Tag })
+ $dlgWin.Close()
+ }.GetNewClosure())
+ $cancelBtn.Add_Click({ $dlgWin.Close() }.GetNewClosure())
+
+ [void]$dlgWin.ShowDialog()
+
+ # If user cancelled or closed, return all subs (don't block scan)
+ if ($null -eq $script:_subSelectorResult) { return $Subscriptions }
+ if ($script:_subSelectorResult.Count -eq 0) { return $Subscriptions }
+ return $script:_subSelectorResult
+}
+
+# -- Export Format Chooser Dialog ----------------------------------------
+function Show-ExportDialog {
+ $d = $script:scanData
+ if (-not $d -or -not $d.Auth) {
+ [System.Windows.MessageBox]::Show('No scan data available. Run a scan first.', 'Export', 'OK', 'Warning')
+ return
+ }
+
+ $dlgXaml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($dlgXaml))
+ $exportWin = [System.Windows.Markup.XamlReader]::Load($reader)
+ $exportWin.Owner = $script:window
+
+ $htmlTile = $exportWin.FindName('HtmlTile')
+ $csvTile = $exportWin.FindName('CsvTile')
+ $pbiTile = $exportWin.FindName('PbiTile')
+
+ # Hover effects
+ $hoverIn = { param($s, $e) $s.Background = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#EBF5FF'); $s.BorderBrush = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#0078D4') }
+ $hoverOut = { param($s, $e) $s.Background = [System.Windows.Media.Brushes]::White; $s.BorderBrush = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#DDD') }
+ foreach ($tile in @($htmlTile, $csvTile, $pbiTile)) {
+ $tile.Add_MouseEnter($hoverIn)
+ $tile.Add_MouseLeave($hoverOut)
+ }
+
+ $htmlTile.Add_MouseLeftButtonDown({
+ $exportWin.Tag = 'HTML'
+ $exportWin.Close()
+ }.GetNewClosure())
+
+ $csvTile.Add_MouseLeftButtonDown({
+ $exportWin.Tag = 'CSV'
+ $exportWin.Close()
+ }.GetNewClosure())
+
+ $pbiTile.Add_MouseLeftButtonDown({
+ $exportWin.Tag = 'PBI'
+ $exportWin.Close()
+ }.GetNewClosure())
+
+ $exportWin.ShowDialog() | Out-Null
+
+ switch ($exportWin.Tag) {
+ 'HTML' { Export-ScanReport -Format 'HTML' }
+ 'CSV' { Export-ScanReport -Format 'CSV' }
+ 'PBI' { Export-PowerBIData }
+ }
+}
+
+# -- Power BI Export Function --------------------------------------------
+function Export-PowerBIData {
+ $d = $script:scanData
+ if (-not $d -or -not $d.Auth) {
+ [System.Windows.MessageBox]::Show('No scan data available. Run a scan first.', 'Export', 'OK', 'Warning')
+ return
+ }
+
+ # Pick export folder via FolderBrowserDialog
+ Add-Type -AssemblyName System.Windows.Forms
+ $fbd = [System.Windows.Forms.FolderBrowserDialog]::new()
+ $fbd.Description = 'Select folder for Power BI export'
+ $fbd.ShowNewFolderButton = $true
+ if ($fbd.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { return }
+
+ $stamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
+ $exportDir = Join-Path $fbd.SelectedPath "FinOps-PowerBI-$stamp"
+ New-Item -Path $exportDir -ItemType Directory -Force | Out-Null
+
+ $fileCount = 0
+
+ # Helper: safe CSV export
+ $writeCsv = {
+ param([string]$Name, [object[]]$Rows)
+ if ($Rows -and $Rows.Count -gt 0) {
+ $Rows | Export-Csv -Path (Join-Path $exportDir "$Name.csv") -NoTypeInformation -Encoding UTF8
+ $script:fileCount++
+ }
+ }
+
+ # 1. Subscription Costs
+ $subRows = @()
+ foreach ($sub in $d.Auth.Subscriptions) {
+ $c = if ($d.Costs -and $d.Costs.ContainsKey($sub.Id)) { $d.Costs[$sub.Id] } else { @{ Actual = 0; Forecast = 0; Currency = 'USD' } }
+ $subRows += [PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ ActualMTD = [math]::Round($c.Actual, 2)
+ Forecast = [math]::Round($c.Forecast, 2)
+ Currency = $c.Currency
+ }
+ }
+ & $writeCsv 'SubscriptionCosts' $subRows
+
+ # 2. Resource Costs
+ if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) {
+ $rcRows = $d.ResourceCosts | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ ResourceGroup = $_.ResourceGroup
+ ResourceType = $_.ResourceType
+ ResourcePath = $_.ResourcePath
+ ActualMTD = [math]::Round($_.Actual, 2)
+ Forecast = [math]::Round($_.Forecast, 2)
+ Currency = $_.Currency
+ }
+ }
+ & $writeCsv 'ResourceCosts' $rcRows
+ }
+
+ # 3. Tag Inventory
+ if ($d.Tags -and $d.Tags.TagNames) {
+ $tagRows = @()
+ foreach ($tn in $d.Tags.TagNames.Keys) {
+ $info = $d.Tags.TagNames[$tn]
+ foreach ($v in $info.Values) {
+ $tagRows += [PSCustomObject]@{
+ TagName = $tn
+ TagValue = $v.Value
+ ResourceCount = $v.ResourceCount
+ }
+ }
+ }
+ & $writeCsv 'TagInventory' $tagRows
+ }
+
+ # 4. Tag Recommendations
+ if ($d.TagRecs -and $d.TagRecs.Analysis) {
+ $trRows = $d.TagRecs.Analysis | ForEach-Object {
+ [PSCustomObject]@{
+ TagName = $_.TagName
+ Status = $_.Status
+ Priority = $_.Priority
+ Pillar = $_.Pillar
+ Purpose = $_.Purpose
+ }
+ }
+ & $writeCsv 'TagRecommendations' $trRows
+ }
+
+ # 5. Policy Inventory
+ if ($d.PolicyInv -and $d.PolicyInv.Assignments) {
+ $piRows = $d.PolicyInv.Assignments | ForEach-Object {
+ [PSCustomObject]@{
+ AssignmentName = $_.AssignmentName
+ PolicyDefId = $_.PolicyDefId
+ Scope = $_.Scope
+ Effect = $_.Effect
+ EnforcementMode = $_.EnforcementMode
+ Origin = $_.Origin
+ Subscription = $_.Subscription
+ }
+ }
+ & $writeCsv 'PolicyInventory' $piRows
+ }
+
+ # 6. Policy Recommendations
+ if ($d.PolicyRecs -and $d.PolicyRecs.Analysis) {
+ $prRows = $d.PolicyRecs.Analysis | ForEach-Object {
+ [PSCustomObject]@{
+ DisplayName = $_.DisplayName
+ Category = $_.Category
+ Pillar = $_.Pillar
+ Priority = $_.Priority
+ DefaultEffect = $_.DefaultEffect
+ Purpose = $_.Purpose
+ Status = if ($_.PolicyDefId -in ($d.PolicyInv.Assignments.PolicyDefId)) { 'Assigned' } else { 'Missing' }
+ }
+ }
+ & $writeCsv 'PolicyRecommendations' $prRows
+ }
+
+ # 7. Budgets
+ if ($d.Budgets -and $d.Budgets.Budgets) {
+ $bRows = $d.Budgets.Budgets | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ SubscriptionId = $_.SubscriptionId
+ BudgetName = $_.BudgetName
+ Amount = $_.Amount
+ TimeGrain = $_.TimeGrain
+ ActualSpend = [math]::Round($_.ActualSpend, 2)
+ Forecast = [math]::Round($_.Forecast, 2)
+ PercentUsed = [math]::Round($_.PctUsed, 1)
+ Risk = $_.Risk
+ Currency = $_.Currency
+ }
+ }
+ & $writeCsv 'Budgets' $bRows
+ }
+
+ # 8. Orphaned Resources
+ if ($d.Orphans -and $d.Orphans.Orphans) {
+ $oRows = $d.Orphans.Orphans | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Category
+ ResourceName = $_.ResourceName
+ ResourceGroup = $_.ResourceGroup
+ SubscriptionId = $_.SubscriptionId
+ Location = $_.Location
+ Detail = $_.Detail
+ Impact = $_.Impact
+ }
+ }
+ & $writeCsv 'OrphanedResources' $oRows
+ }
+
+ # 9. Cost by Tag
+ if ($d.CostByTag -and $d.CostByTag.CostByTag) {
+ $ctRows = @()
+ foreach ($tagKey in $d.CostByTag.CostByTag.Keys) {
+ foreach ($entry in $d.CostByTag.CostByTag[$tagKey]) {
+ $ctRows += [PSCustomObject]@{
+ TagName = $tagKey
+ TagValue = $entry.TagValue
+ Cost = [math]::Round($entry.Cost, 2)
+ Currency = $entry.Currency
+ }
+ }
+ }
+ & $writeCsv 'CostByTag' $ctRows
+ }
+
+ # 10. Cost Trend
+ if ($d.CostTrend -and $d.CostTrend.HasData -and $d.CostTrend.Months) {
+ $tRows = $d.CostTrend.Months | ForEach-Object {
+ [PSCustomObject]@{
+ Month = $_.Month
+ Cost = [math]::Round($_.Cost, 2)
+ Currency = $_.Currency
+ }
+ }
+ & $writeCsv 'CostTrend' $tRows
+ }
+
+ # 11. Commitment Utilization
+ if ($d.Commitments -and $d.Commitments.HasData) {
+ $cmRows = @()
+ if ($d.Commitments.Reservations) {
+ $cmRows += $d.Commitments.Reservations | ForEach-Object {
+ [PSCustomObject]@{
+ Type = 'Reservation'
+ Id = $_.ReservationId
+ SkuName = $_.SkuName
+ AvgUtilization = [math]::Round($_.AvgUtilization, 1)
+ MinUtilization = [math]::Round($_.MinUtilization, 1)
+ MaxUtilization = [math]::Round($_.MaxUtilization, 1)
+ ReservedHours = $_.ReservedHours
+ UsedHours = $_.UsedHours
+ }
+ }
+ }
+ if ($d.Commitments.SavingsPlans) {
+ $cmRows += $d.Commitments.SavingsPlans | ForEach-Object {
+ [PSCustomObject]@{
+ Type = 'SavingsPlan'
+ Id = $_.BenefitId
+ SkuName = ''
+ AvgUtilization = [math]::Round($_.AvgUtilization, 1)
+ MinUtilization = 0
+ MaxUtilization = 0
+ ReservedHours = 0
+ UsedHours = 0
+ }
+ }
+ }
+ & $writeCsv 'CommitmentUtilization' $cmRows
+ }
+
+ # 12. AHB Opportunities
+ if ($d.AHB -and $d.AHB.TotalOpportunities -gt 0) {
+ $ahbRows = @()
+ foreach ($prop in @('WindowsVMs', 'SQLVMs', 'SQLDatabases')) {
+ if ($d.AHB.$prop) {
+ $ahbRows += $d.AHB.$prop | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $prop
+ ResourceName = $_.name
+ ResourceGroup = $_.resourceGroup
+ SubscriptionId = $_.subscriptionId
+ Location = $_.location
+ }
+ }
+ }
+ }
+ & $writeCsv 'AHBOpportunities' $ahbRows
+ }
+
+ # 13. Optimization / Advisor Recommendations
+ if ($d.Optimization -and $d.Optimization.Recommendations) {
+ $optRows = $d.Optimization.Recommendations | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ Category = $_.Category
+ Impact = $_.Impact
+ Problem = $_.Problem
+ Solution = $_.Solution
+ ResourceType = $_.ResourceType
+ ResourceName = $_.ResourceName
+ AnnualSavings = if ($_.AnnualSavings) { [math]::Round($_.AnnualSavings, 2) } else { '' }
+ Currency = $_.Currency
+ }
+ }
+ & $writeCsv 'OptimizationAdvice' $optRows
+ }
+
+ # 14. Reservation Recommendations
+ if ($d.Reservations) {
+ $resRows = @()
+ if ($d.Reservations.AdvisorRecommendations) {
+ $resRows += $d.Reservations.AdvisorRecommendations | ForEach-Object {
+ [PSCustomObject]@{
+ Source = 'Advisor'
+ Subscription = $_.Subscription
+ ResourceType = $_.ResourceType
+ Impact = $_.Impact
+ Problem = $_.Problem
+ AnnualSavings = if ($_.AnnualSavings) { [math]::Round($_.AnnualSavings, 2) } else { '' }
+ Term = $_.Term
+ Currency = $_.Currency
+ }
+ }
+ }
+ if ($d.Reservations.ReservationRecommendations) {
+ $resRows += $d.Reservations.ReservationRecommendations | ForEach-Object {
+ [PSCustomObject]@{
+ Source = 'ReservationAPI'
+ Subscription = ''
+ ResourceType = $_.ResourceType
+ Impact = ''
+ Problem = "Buy $($_.RecommendedQty)x $($_.SKU) ($($_.Term))"
+ AnnualSavings = if ($_.NetSavings) { [math]::Round($_.NetSavings, 2) } else { '' }
+ Term = $_.Term
+ Currency = ''
+ }
+ }
+ }
+ & $writeCsv 'ReservationAdvice' $resRows
+ }
+
+ # 15. Savings Realized
+ if ($d.Savings -and $d.Savings.HasData -and $d.Savings.Details) {
+ $sRows = $d.Savings.Details | ForEach-Object {
+ [PSCustomObject]@{
+ Subscription = $_.Subscription
+ Category = $_.Category
+ Amount = [math]::Round($_.Amount, 2)
+ Type = $_.Type
+ }
+ }
+ & $writeCsv 'SavingsRealized' $sRows
+ }
+
+ # 16. Scorecard (pre-computed)
+ if ($script:ScorecardGrid.ItemsSource) {
+ & $writeCsv 'Scorecard' @($script:ScorecardGrid.ItemsSource)
+ }
+
+ # ================================================================
+ # Generate Power BI Template (.pbit)
+ # ================================================================
+ Add-Type -AssemblyName System.IO.Compression
+
+ $csvFiles = Get-ChildItem -Path $exportDir -Filter '*.csv'
+ $numericCols = @('ActualMTD', 'Forecast', 'Cost', 'Amount', 'ActualSpend', 'PercentUsed', 'AnnualSavings', 'AvgUtilization', 'MinUtilization', 'MaxUtilization', 'ReservedHours', 'UsedHours', 'ResourceCount')
+ $exportDirEscaped = $exportDir -replace '\\', '\\\\'
+
+ # Build DataModelSchema JSON manually to avoid ConvertTo-Json issues
+ $sb = [System.Text.StringBuilder]::new(8192)
+ [void]$sb.Append('{"name":"Model","compatibilityLevel":1550,"model":{"culture":"en-US","dataAccessOptions":{"legacyRedirects":true,"returnErrorValuesAsNull":true},"defaultPowerBIDataSourceVersion":"powerBI_V3","sourceQueryCulture":"en-US","tables":[')
+
+ # CsvFolderPath parameter table
+ $paramGuid = [guid]::NewGuid().ToString()
+ $paramColGuid = [guid]::NewGuid().ToString()
+ [void]$sb.Append('{"name":"CsvFolderPath","lineageTag":"' + $paramGuid + '","columns":[{"name":"CsvFolderPath","dataType":"string","isHidden":true,"sourceColumn":"CsvFolderPath","lineageTag":"' + $paramColGuid + '"}],"partitions":[{"name":"CsvFolderPath","mode":"import","source":{"type":"m","expression":["\"' + $exportDirEscaped + '\" meta [IsParameterQuery=true, Type=\"Text\", IsParameterQueryRequired=true]"]}}],"annotations":[{"name":"PBI_ResultType","value":"Text"},{"name":"PBI_NavigationStepName","value":"Navigation"}]}')
+
+ # Data tables from CSVs
+ foreach ($csv in $csvFiles) {
+ $tblName = [System.IO.Path]::GetFileNameWithoutExtension($csv.Name)
+ $headerLine = Get-Content $csv.FullName -First 1
+ $headers = ($headerLine -replace '"', '') -split ','
+ $tblGuid = [guid]::NewGuid().ToString()
+
+ [void]$sb.Append(',{"name":"' + $tblName + '","lineageTag":"' + $tblGuid + '","columns":[')
+ $colFragments = @()
+ $typeCasts = @()
+ foreach ($h in $headers) {
+ $cGuid = [guid]::NewGuid().ToString()
+ $isNum = $h -in $numericCols
+ $dt = if ($isNum) { 'double' } else { 'string' }
+ $sum = if ($isNum) { 'sum' } else { 'none' }
+ $colFragments += '{"name":"' + $h + '","dataType":"' + $dt + '","sourceColumn":"' + $h + '","summarizeBy":"' + $sum + '","lineageTag":"' + $cGuid + '"}'
+ if ($isNum) { $typeCasts += '{\"' + $h + '\", type number}' }
+ }
+ [void]$sb.Append($colFragments -join ',')
+ [void]$sb.Append('],')
+
+ # Partition with M expression
+ $mExpr = @()
+ $mExpr += '"let"'
+ if ($typeCasts.Count -gt 0) {
+ $mExpr += '" Source = Csv.Document(File.Contents(CsvFolderPath & \"\\\\' + $tblName + '.csv\"), [Delimiter=\",\", Encoding=65001, QuoteStyle=QuoteStyle.Csv]),"'
+ $mExpr += '" Headers = Table.PromoteHeaders(Source, [PromoteAllScalars=true]),"'
+ $castStr = $typeCasts -join ', '
+ $mExpr += '" Typed = Table.TransformColumnTypes(Headers, {' + $castStr + '})"'
+ $mExpr += '"in"'
+ $mExpr += '" Typed"'
+ }
+ else {
+ $mExpr += '" Source = Csv.Document(File.Contents(CsvFolderPath & \"\\\\' + $tblName + '.csv\"), [Delimiter=\",\", Encoding=65001, QuoteStyle=QuoteStyle.Csv]),"'
+ $mExpr += '" Headers = Table.PromoteHeaders(Source, [PromoteAllScalars=true])"'
+ $mExpr += '"in"'
+ $mExpr += '" Headers"'
+ }
+
+ [void]$sb.Append('"partitions":[{"name":"' + $tblName + '","mode":"import","source":{"type":"m","expression":[' + ($mExpr -join ',') + ']}}]}')
+ }
+
+ [void]$sb.Append('],') # end tables
+
+ # Relationships
+ $tblNames = @('CsvFolderPath') + @($csvFiles | ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_.Name) })
+ $relFragments = @()
+ $subIdTables = @('Budgets', 'OrphanedResources', 'AHBOpportunities')
+ foreach ($ft in $subIdTables) {
+ if ($ft -in $tblNames -and 'SubscriptionCosts' -in $tblNames) {
+ $rGuid = [guid]::NewGuid().ToString()
+ $relFragments += '{"name":"' + $rGuid + '","fromTable":"' + $ft + '","fromColumn":"SubscriptionId","toTable":"SubscriptionCosts","toColumn":"SubscriptionId"}'
+ }
+ }
+ [void]$sb.Append('"relationships":[' + ($relFragments -join ',') + '],')
+ [void]$sb.Append('"annotations":[{"name":"PBI_QueryGroup","value":"{}"},{"name":"PBIDesktopVersion","value":"2.138.0.0"}]}}')
+
+ $modelJson = $sb.ToString()
+
+ # Clone skeleton .pbit and inject our DataModelSchema
+ $rootDir = $script:ScriptRootDir
+ if (-not $rootDir) { $rootDir = $PSScriptRoot }
+ if (-not $rootDir) { $rootDir = Split-Path -Parent $MyInvocation.ScriptName }
+ if (-not $rootDir) { $rootDir = Split-Path -Parent (Get-Item $MyInvocation.MyCommand.Path -ErrorAction SilentlyContinue).FullName }
+ $skelPath = Join-Path (Join-Path $rootDir 'gui') 'skeleton.pbit'
+ if (-not (Test-Path $skelPath)) {
+ [System.Windows.MessageBox]::Show("skeleton.pbit not found at:`n$skelPath`n`nScriptRootDir=$($script:ScriptRootDir)`nPSScriptRoot=$PSScriptRoot", 'Power BI Export Error', 'OK', 'Error')
+ return
+ }
+ $pbitPath = Join-Path $exportDir 'FinOps-Report.pbit'
+ Copy-Item $skelPath $pbitPath -Force
+ if ((Get-Item $pbitPath).Length -lt 1000) {
+ [System.Windows.MessageBox]::Show("skeleton.pbit copy failed — file too small.`nSource: $skelPath`nDest: $pbitPath", 'Power BI Export Error', 'OK', 'Error')
+ return
+ }
+
+ $unicodeNoBom = [System.Text.UnicodeEncoding]::new($false, $false)
+ $zip = [System.IO.Compression.ZipFile]::Open($pbitPath, [System.IO.Compression.ZipArchiveMode]::Update)
+ try {
+ $dmEntry = $zip.Entries | Where-Object { $_.FullName -eq 'DataModelSchema' }
+ if (-not $dmEntry) { throw 'DataModelSchema entry not found in skeleton' }
+ $dmName = $dmEntry.FullName
+ $dmEntry.Delete()
+ $newDm = $zip.CreateEntry($dmName)
+ $sw = [System.IO.StreamWriter]::new($newDm.Open(), $unicodeNoBom)
+ $sw.Write($modelJson)
+ $sw.Close()
+ }
+ finally {
+ $zip.Dispose()
+ }
+
+ $csvCount = $csvFiles.Count
+ Update-UIStatus "Power BI export: $csvCount CSVs + template saved to $exportDir" $script:ProgressBar.Value
+ [System.Windows.MessageBox]::Show("Exported $csvCount CSVs + Power BI template to:`n$exportDir`n`nOpen FinOps-Report.pbit in Power BI Desktop.`nThe CsvFolderPath parameter is pre-set to this folder.", 'Power BI Export', 'OK', 'Information')
+}
+
+# -- Export Function ----------------------------------------------------
+function Export-ScanReport {
+ param([string]$Format)
+ $d = $script:scanData
+
+ if ($Format -eq 'CSV') {
+ $dlg = [Microsoft.Win32.SaveFileDialog]::new()
+ $dlg.Filter = "CSV File (*.csv)|*.csv"
+ $dlg.FileName = "FinOps-Report-$(Get-Date -Format 'yyyy-MM-dd')"
+ if ($dlg.ShowDialog() -ne $true) { return }
+ $path = $dlg.FileName
+ }
+ elseif ($Format -eq 'HTML') {
+ $dlg = [Microsoft.Win32.SaveFileDialog]::new()
+ $dlg.Filter = "HTML Report (*.html)|*.html"
+ $dlg.FileName = "FinOps-Report-$(Get-Date -Format 'yyyy-MM-dd')"
+ if ($dlg.ShowDialog() -ne $true) { return }
+ $path = $dlg.FileName
+ }
+ else {
+ # Legacy fallback — combined dialog
+ $dlg = [Microsoft.Win32.SaveFileDialog]::new()
+ $dlg.Filter = "HTML Report (*.html)|*.html|CSV File (*.csv)|*.csv"
+ $dlg.FileName = "FinOps-Report-$(Get-Date -Format 'yyyy-MM-dd')"
+ $dlg.FilterIndex = 1
+ if ($dlg.ShowDialog() -ne $true) { return }
+ $path = $dlg.FileName
+ }
+ $path = $dlg.FileName
+
+ if ($path -match '\.csv$') {
+ # CSV - subscription costs
+ $rows = @()
+ foreach ($sub in $d.Auth.Subscriptions) {
+ $c = if ($d.Costs -and $d.Costs.ContainsKey($sub.Id)) { $d.Costs[$sub.Id] } else { @{ Actual = 0; Forecast = 0; Currency = 'USD' } }
+ $rows += [PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ ActualMTD = $c.Actual
+ Forecast = $c.Forecast
+ Currency = $c.Currency
+ }
+ }
+ $rows | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8
+ Update-UIStatus "CSV exported to $path" $script:ProgressBar.Value
+ return
+ }
+
+ # ================================================================
+ # HTML REPORT - Professional FinOps Assessment
+ # ================================================================
+ $esc = [System.Security.SecurityElement]
+
+ # Currency helper
+ $sym = '$'
+ if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) {
+ $sym = Get-CurrencySymbol -Code $d.ResourceCosts[0].Currency
+ }
+
+ # Use pre-computed maturity score from Populate-GuidanceTab
+ $rptScore = if ($d.MaturityScore) { $d.MaturityScore } else { 0 }
+ $rptBreakdown = if ($d.MaturityBreakdown) { $d.MaturityBreakdown } else { @{} }
+ $gradeLabel = if ($d.MaturityGrade) { $d.MaturityGrade } else { 'Getting Started' }
+ $gradeColor = if ($d.MaturityGradeColor) { $d.MaturityGradeColor } else { '#E81123' }
+
+ # Total spend
+ $totalActual = 0.0; $totalForecast = 0.0
+ if ($d.Costs) { foreach ($k in $d.Costs.Keys) { $totalActual += $d.Costs[$k].Actual; $totalForecast += $d.Costs[$k].Forecast } }
+
+ # Build HTML
+ $sb = [System.Text.StringBuilder]::new(32768)
+ [void]$sb.Append(@"
+
+
+
+
+Azure FinOps Assessment Report
+
+
+
+
+
+
+"@)
+
+ # == 1. EXECUTIVE SUMMARY ==
+ [void]$sb.Append(@"
+1. Executive Summary
+
+
Total Spend (MTD)
$sym$($totalActual.ToString('N2'))
Forecast: $sym$($totalForecast.ToString('N2'))
+
FinOps Maturity
$rptScore / 100
$gradeLabel
+
Subscriptions
$($d.Auth.Subscriptions.Count)
Scanned
+"@)
+ if ($d.Tags) {
+ [void]$sb.Append("
Tag Coverage
$([math]::Round($d.Tags.TagCoverage,1))%
$($d.Tags.TaggedCount) of $($d.Tags.TotalResources) resources
")
+ }
+ if ($d.PolicyInv) {
+ [void]$sb.Append("
Policy Compliance
$([math]::Round($d.PolicyInv.CompliancePct,1))%
$($d.PolicyInv.TotalNonCompliant) non-compliant
")
+ }
+ $optTotal = 0
+ if ($d.Orphans) { $optTotal += $d.Orphans.TotalCount }
+ if ($d.AHB) { $optTotal += $d.AHB.TotalOpportunities }
+ if ($d.Optimization) { $optTotal += $d.Optimization.TotalCount }
+ [void]$sb.Append("
Optimizations Found
$optTotal
AHB + Orphans + Advisor
")
+ [void]$sb.Append("
")
+
+ # == 2. MATURITY SCORE ==
+ [void]$sb.Append(@"
+2. FinOps Maturity Score
+
+$rptScore
+$gradeLabel
+
+Score based on FinOps Foundation Maturity Model and Microsoft Cloud Adoption Framework. Categories: Visibility (25), Allocation (20), Budgeting (15), Optimization (20), Governance (20).
+
+"@)
+ foreach ($cat in @('Visibility', 'Allocation', 'Budgeting', 'Optimization', 'Governance')) {
+ $catMax = switch ($cat) { 'Visibility' { 25 } 'Allocation' { 20 } 'Budgeting' { 15 } default { 20 } }
+ $catVal = if ($rptBreakdown.ContainsKey($cat)) { $rptBreakdown[$cat] } else { 0 }
+ $pct = if ($catMax -gt 0) { [math]::Round(($catVal / $catMax) * 100) } else { 0 }
+ [void]$sb.Append("
")
+ }
+ [void]$sb.Append("
")
+
+ # == 3. COST OVERVIEW ==
+ [void]$sb.Append(@"
+3. Cost Overview by Subscription
+
+Subscription Subscription ID Actual (MTD) Forecast Tag Coverage Budget Status Cost Trend
+"@)
+ foreach ($sub in $d.Auth.Subscriptions | Sort-Object { if ($d.Costs -and $d.Costs.ContainsKey($_.Id)) { $d.Costs[$_.Id].Actual } else { 0 } } -Descending) {
+ $c = if ($d.Costs -and $d.Costs.ContainsKey($sub.Id)) { $d.Costs[$sub.Id] } else { @{ Actual = 0; Forecast = 0 } }
+
+ # Tag coverage per sub
+ $tagPct = '-'
+ if ($d.Tags -and $d.Tags.RawResults) {
+ $subRes = @($d.Tags.RawResults | Where-Object { $_.subscriptionId -eq $sub.Id })
+ if ($subRes.Count -gt 0) {
+ $tagged = @($subRes | Where-Object { $_.tags -and $_.tags.PSObject.Properties.Count -gt 0 }).Count
+ $tagPct = "$([math]::Round(($tagged / $subRes.Count) * 100, 1))%"
+ }
+ }
+
+ # Budget status
+ $budgetTxt = '-'
+ if ($d.Budgets -and $d.Budgets.Budgets) {
+ $subBudgets = @($d.Budgets.Budgets | Where-Object { $_.SubscriptionId -eq $sub.Id })
+ if ($subBudgets.Count -gt 0) {
+ $worstRisk = ($subBudgets | Sort-Object PctUsed -Descending | Select-Object -First 1).Risk
+ $budgetTxt = $worstRisk
+ }
+ else { $budgetTxt = 'No Budget' }
+ }
+ $budgetClass = switch ($budgetTxt) { 'Over Budget' { 'status-warn' } 'At Risk' { 'status-warn' } 'On Track' { 'status-good' } default { 'text-muted' } }
+
+ # Cost trend
+ $trendTxt = '-'
+ if ($d.CostTrend -and $d.CostTrend.HasData -and $d.CostTrend.Months.Count -ge 2) {
+ $last = $d.CostTrend.Months[-1].Cost; $prev = $d.CostTrend.Months[-2].Cost
+ if ($prev -gt 0) {
+ $pctChg = [math]::Round((($last - $prev) / $prev) * 100, 1)
+ $trendTxt = if ($pctChg -gt 5) { "Up $pctChg%" } elseif ($pctChg -lt -5) { "Down $([math]::Abs($pctChg))%" } else { 'Stable' }
+ }
+ }
+
+ [void]$sb.Append("$($esc::Escape($sub.Name)) $($sub.Id) ")
+ [void]$sb.Append("$sym$($c.Actual.ToString('N2')) $sym$($c.Forecast.ToString('N2')) ")
+ [void]$sb.Append("$tagPct $budgetTxt $trendTxt ")
+ }
+ [void]$sb.Append("Total $sym$($totalActual.ToString('N2')) $sym$($totalForecast.ToString('N2')) ")
+ [void]$sb.Append("
")
+
+ # == 4. COST TREND ==
+ [void]$sb.Append('4. 6-Month Cost Trend ')
+ if ($d.CostTrend -and $d.CostTrend.HasData -and $d.CostTrend.Months.Count -gt 0) {
+ # Aggregate trend
+ [void]$sb.Append('All Subscriptions ')
+ $months = $d.CostTrend.Months
+ $maxCost = ($months | Measure-Object -Property Cost -Maximum).Maximum
+ if ($maxCost -le 0) { $maxCost = 1 }
+ [void]$sb.Append("Month Spend Bar ")
+ foreach ($m in $months) {
+ $barW = [math]::Round(($m.Cost / $maxCost) * 100)
+ [void]$sb.Append("$($esc::Escape($m.Month)) $sym$($m.Cost.ToString('N2')) ")
+ [void]$sb.Append("
")
+ }
+ [void]$sb.Append("
")
+
+ # Per-subscription trends
+ if ($d.CostTrend.BySubscription -and $d.CostTrend.BySubscription.Count -gt 0 -and $d.Auth.Subscriptions.Count -gt 1) {
+ foreach ($sub in $d.Auth.Subscriptions) {
+ if ($d.CostTrend.BySubscription.ContainsKey($sub.Id)) {
+ $subMonths = $d.CostTrend.BySubscription[$sub.Id]
+ if ($subMonths.Count -gt 0) {
+ $subMax = ($subMonths | Measure-Object -Property Cost -Maximum).Maximum
+ if ($subMax -le 0) { $subMax = 1 }
+ [void]$sb.Append("$($esc::Escape($sub.Name)) ")
+ [void]$sb.Append("Month Spend Bar ")
+ foreach ($sm in $subMonths) {
+ $bw = [math]::Round(($sm.Cost / $subMax) * 100)
+ [void]$sb.Append("$($esc::Escape($sm.Month)) $sym$($sm.Cost.ToString('N2')) ")
+ [void]$sb.Append("
")
+ }
+ [void]$sb.Append("
")
+ }
+ }
+ }
+ }
+ }
+ else {
+ [void]$sb.Append('No cost trend data available.
')
+ }
+
+ # == 5. RESOURCE COSTS ==
+ [void]$sb.Append('
5. Top Resource Costs ')
+ if ($d.ResourceCosts -and $d.ResourceCosts.Count -gt 0) {
+ $topResources = $d.ResourceCosts | Sort-Object Actual -Descending | Select-Object -First 50
+ [void]$sb.Append("Showing top $([math]::Min(50, $d.ResourceCosts.Count)) of $($d.ResourceCosts.Count) resources by MTD cost.
")
+ [void]$sb.Append("Resource Type Resource Group Subscription Actual (MTD) Forecast ")
+ foreach ($r in $topResources) {
+ $resName = ($r.ResourcePath -split '/')[-1]
+ [void]$sb.Append("$($esc::Escape($resName)) $($esc::Escape($r.ResourceType)) ")
+ [void]$sb.Append("$($esc::Escape($r.ResourceGroup)) $($esc::Escape($r.Subscription)) ")
+ [void]$sb.Append("$sym$($r.Actual.ToString('N2')) $sym$($r.Forecast.ToString('N2')) ")
+ }
+ [void]$sb.Append("
")
+ }
+ else {
+ [void]$sb.Append('No resource-level cost data available.
')
+ }
+
+ # == 6. TAG COMPLIANCE ==
+ [void]$sb.Append('6. Tag Compliance ')
+ if ($d.Tags) {
+ [void]$sb.Append(@"
+
+
Tag Coverage
$([math]::Round($d.Tags.TagCoverage,1))%
$($d.Tags.TaggedCount) tagged / $($d.Tags.TotalResources) total
+
Unique Tags
$($d.Tags.TagCount)
Distinct tag names
+
Untagged Resources
$($d.Tags.UntaggedCount)
+
+"@)
+ # Tag inventory table
+ if ($d.Tags.TagNames -and $d.Tags.TagNames.Count -gt 0) {
+ [void]$sb.Append("Tag Inventory ($($d.Tags.TagNames.Count) tags) ")
+ [void]$sb.Append('Tag Name Resources Unique Values Sample Values ')
+ foreach ($entry in $d.Tags.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending) {
+ $allValues = @($entry.Value.Values | ForEach-Object { $_.Value })
+ $sampleValues = ($allValues | Select-Object -First 5) -join ', '
+ if ($allValues.Count -gt 5) { $sampleValues += ", ... (+$($allValues.Count - 5) more)" }
+ [void]$sb.Append("$($esc::Escape($entry.Key)) $($entry.Value.TotalResources) $($allValues.Count) $($esc::Escape($sampleValues)) ")
+ }
+ [void]$sb.Append('
')
+ }
+ # CAF recommended tags
+ if ($d.TagRecs) {
+ [void]$sb.Append("Microsoft CAF Recommended Tags ")
+ [void]$sb.Append("Tag Name Status Location Purpose ")
+ foreach ($tr in $d.TagRecs.Analysis) {
+ $statusCls = if ($tr.Status -eq 'Present') { 'status-assigned' } else { 'status-missing' }
+ $locText = if ($tr.Location) { $esc::Escape($tr.Location) } else { '-' }
+ [void]$sb.Append("$($esc::Escape($tr.TagName)) $($tr.Status) $locText $($esc::Escape($tr.Purpose)) ")
+ }
+ [void]$sb.Append("
")
+ }
+ # Untagged resources detail list
+ if ($d.Tags.UntaggedResources -and $d.Tags.UntaggedResources.Count -gt 0) {
+ $utShown = $d.Tags.UntaggedResources.Count
+ $utTotal = $d.Tags.UntaggedCount
+ $utNote = if ($utShown -lt $utTotal) { " (showing $utShown of $utTotal)" } else { "" }
+ [void]$sb.Append("Untagged Resources$utNote ")
+ [void]$sb.Append("Resource Name Resource Type Resource Group Subscription Location ")
+ foreach ($ur in $d.Tags.UntaggedResources) {
+ [void]$sb.Append("$($esc::Escape($ur.ResourceName)) $($esc::Escape($ur.ResourceType)) $($esc::Escape($ur.ResourceGroup)) $($esc::Escape($ur.Subscription)) $($esc::Escape($ur.Location)) ")
+ }
+ [void]$sb.Append("
")
+ }
+ }
+ else {
+ [void]$sb.Append('No tag data available.
')
+ }
+
+ # == 7. POLICY COMPLIANCE ==
+ [void]$sb.Append('
7. Policy Compliance ')
+ if ($d.PolicyInv) {
+ [void]$sb.Append(@"
+
+
Policy Assignments
$($d.PolicyInv.AssignmentCount)
+
Compliance
$([math]::Round($d.PolicyInv.CompliancePct,1))%
+
Non-Compliant Resources
$($d.PolicyInv.TotalNonCompliant)
+
+"@)
+ # Per-subscription compliance
+ if ($d.PolicyInv.ComplianceBySubMap -and $d.PolicyInv.ComplianceBySubMap.Count -gt 0) {
+ [void]$sb.Append("Per-Subscription Compliance Subscription Compliant Non-Compliant Total Compliance % ")
+ foreach ($sk in $d.PolicyInv.ComplianceBySubMap.Keys) {
+ $cs = $d.PolicyInv.ComplianceBySubMap[$sk]
+ $cpct = if (($cs.Compliant + $cs.NonCompliant) -gt 0) { [math]::Round(($cs.Compliant / ($cs.Compliant + $cs.NonCompliant)) * 100, 1) } else { 0 }
+ [void]$sb.Append("$($esc::Escape($cs.Subscription)) $($cs.Compliant) $($cs.NonCompliant) $($cs.TotalResources) $cpct% ")
+ }
+ [void]$sb.Append("
")
+ }
+ # Policy assignment inventory
+ if ($d.PolicyInv.Assignments -and $d.PolicyInv.Assignments.Count -gt 0) {
+ [void]$sb.Append("Policy Assignment Inventory ($($d.PolicyInv.Assignments.Count) assignments) ")
+ [void]$sb.Append('Assignment Name Type Effect Enforcement Origin Subscription ')
+ foreach ($pa in $d.PolicyInv.Assignments) {
+ $paType = if ($pa.PolicyDefId -match '/policySetDefinitions/') { 'Initiative' } else { 'Policy' }
+ [void]$sb.Append("$($esc::Escape($pa.AssignmentName)) $paType $($esc::Escape($pa.Effect)) $($esc::Escape($pa.EnforcementMode)) $($esc::Escape($pa.Origin)) $($esc::Escape($pa.Subscription)) ")
+ }
+ [void]$sb.Append('
')
+ }
+ }
+
+ # FinOps Policy Recommendations
+ if ($d.PolicyRecs) {
+ [void]$sb.Append("FinOps Recommended Policies ($($d.PolicyRecs.Assigned.Count) of $($d.PolicyRecs.Analysis.Count) assigned) ")
+ [void]$sb.Append("Policy Status Category Priority Pillar Purpose ")
+ foreach ($pr in $d.PolicyRecs.Analysis | Sort-Object { switch ($_.Priority) { 'Required' { 0 } 'Recommended' { 1 } 'Optional' { 2 } default { 3 } } }) {
+ $sCls = if ($pr.Status -eq 'Assigned') { 'status-assigned' } else { 'status-missing' }
+ [void]$sb.Append("$($esc::Escape($pr.DisplayName)) $($pr.Status) ")
+ [void]$sb.Append("$($esc::Escape($pr.Category)) $($pr.Priority) $($pr.Pillar) $($esc::Escape($pr.Purpose)) ")
+ }
+ [void]$sb.Append("
")
+ }
+
+ # == 8. OPTIMIZATION ==
+ [void]$sb.Append('8. Optimization Opportunities ')
+ # AHB
+ if ($d.AHB -and $d.AHB.TotalOpportunities -gt 0) {
+ [void]$sb.Append("Azure Hybrid Benefit Opportunities ($($d.AHB.TotalOpportunities)) ")
+ [void]$sb.Append("$($esc::Escape($d.AHB.Summary))
")
+ if ($d.AHB.WindowsVMs.Count -gt 0) {
+ [void]$sb.Append("VM Name Resource Group Size Location Current License ")
+ foreach ($vm in $d.AHB.WindowsVMs) {
+ [void]$sb.Append("$($esc::Escape($vm.name)) $($esc::Escape($vm.resourceGroup)) $($esc::Escape($vm.vmSize)) $($esc::Escape($vm.location)) $($esc::Escape($vm.currentLicense)) ")
+ }
+ [void]$sb.Append("
")
+ }
+ }
+ # Orphans
+ if ($d.Orphans -and $d.Orphans.TotalCount -gt 0) {
+ [void]$sb.Append("Orphaned / Idle Resources ($($d.Orphans.TotalCount)) ")
+ [void]$sb.Append("Category Resource Resource Group Impact Detail ")
+ foreach ($o in $d.Orphans.Orphans | Sort-Object Impact -Descending) {
+ $impCls = switch ($o.Impact) { 'High' { 'status-warn' } 'Medium' { 'status-info' } default { 'text-muted' } }
+ [void]$sb.Append("$($esc::Escape($o.Category)) $($esc::Escape($o.ResourceName)) $($esc::Escape($o.ResourceGroup)) $($o.Impact) $($esc::Escape($o.Detail)) ")
+ }
+ [void]$sb.Append("
")
+ }
+ # Advisor
+ if ($d.Optimization -and $d.Optimization.TotalCount -gt 0) {
+ [void]$sb.Append("Azure Advisor Cost Recommendations ($($d.Optimization.TotalCount)) ")
+ if ($d.Optimization.EstimatedAnnualSavings -gt 0) {
+ [void]$sb.Append("Estimated annual savings: $sym$($d.Optimization.EstimatedAnnualSavings.ToString('N2'))
")
+ }
+ [void]$sb.Append("Subscription Category Impact Problem Solution Annual Savings ")
+ foreach ($rec in $d.Optimization.Recommendations | Sort-Object { switch ($_.Impact) { 'High' { 0 } 'Medium' { 1 } default { 2 } } }) {
+ $impCls = switch ($rec.Impact) { 'High' { 'status-warn' } 'Medium' { 'status-info' } default { 'text-muted' } }
+ $savings = if ($rec.AnnualSavings -and $rec.AnnualSavings -gt 0) { "$sym$($rec.AnnualSavings.ToString('N2'))" } else { '-' }
+ [void]$sb.Append("$($esc::Escape($rec.Subscription)) $($esc::Escape($rec.Category)) $($rec.Impact) ")
+ [void]$sb.Append("$($esc::Escape($rec.Problem)) $($esc::Escape($rec.Solution)) $savings ")
+ }
+ [void]$sb.Append("
")
+ }
+ if ($optTotal -eq 0) {
+ [void]$sb.Append('No optimization issues found. Well optimized!
')
+ }
+
+ # == 9. BUDGETS ==
+ [void]$sb.Append('
9. Budget Status ')
+ if ($d.Budgets -and $d.Budgets.HasData) {
+ [void]$sb.Append(@"
+
+
Total Budgets
$($d.Budgets.TotalBudgets)
+
Budget Coverage
$([math]::Round($d.Budgets.BudgetCoverage,0))%
$($d.Budgets.SubsWithBudget) of $($d.Budgets.SubsWithBudget + $d.Budgets.SubsWithoutBudget) subscriptions
+
Over Budget
$($d.Budgets.OverBudgetCount)
+
At Risk
$($d.Budgets.AtRiskCount)
+
+"@)
+ [void]$sb.Append("Subscription Budget Name Amount Actual Spend % Used Risk ")
+ foreach ($b in $d.Budgets.Budgets | Sort-Object PctUsed -Descending) {
+ $riskCls = switch ($b.Risk) { 'Over Budget' { 'status-warn' } 'At Risk' { 'status-warn' } 'On Track' { 'status-good' } default { 'text-muted' } }
+ [void]$sb.Append("$($esc::Escape($b.Subscription)) $($esc::Escape($b.BudgetName)) ")
+ [void]$sb.Append("$sym$($b.Amount.ToString('N2')) $sym$($b.ActualSpend.ToString('N2')) ")
+ [void]$sb.Append("$([math]::Round($b.PctUsed,1))% $($b.Risk) ")
+ }
+ [void]$sb.Append("
")
+ }
+ else {
+ [void]$sb.Append('No budgets configured. Consider creating budgets for all production subscriptions.
')
+ }
+
+ # == 10. ACTIONS TAKEN ==
+ if ($script:actionLog.Count -gt 0) {
+ [void]$sb.Append('10. Actions Taken This Session ')
+ [void]$sb.Append('
The following changes were made during this scan session:
')
+ [void]$sb.Append('
Time Action Detail ')
+ foreach ($entry in $script:actionLog) {
+ [void]$sb.Append("$($esc::Escape($entry.Time)) $($esc::Escape($entry.Type)) $($esc::Escape($entry.Detail)) ")
+ }
+ [void]$sb.Append('
')
+ }
+
+ # Footer
+ [void]$sb.Append(@"
+
+Generated by Azure FinOps Multitool — $(Get-Date -Format 'MMMM d, yyyy h:mm tt')
+Based on FinOps Foundation Framework and Microsoft Cloud Adoption Framework for Azure.
+Tip: Use your browser's Print function (Ctrl+P) and select "Save as PDF" for a PDF version of this report.
+
+
+
+"@)
+
+ [System.IO.File]::WriteAllText($path, $sb.ToString(), [System.Text.Encoding]::UTF8)
+ Update-UIStatus "Report exported to $path" $script:ProgressBar.Value
+
+ # Auto-open the report
+ try { Start-Process $path } catch { }
+}
+
+###########################################################################
+# SCAN STAGES (DispatcherTimer-based staged loading)
+###########################################################################
+$script:scanStages = @(
+ @{ Label = 'Verifying tenant context...'; Pct = 5; Action = {
+ if (-not $script:scanData.Auth) {
+ throw "No tenant selected. Click 'Commercial Tenant' or 'Gov Tenant' first."
+ }
+ $script:MgCostScopeFailed = $false # Reset MG-scope flag for fresh scan
+ $envLabel = $script:scanData.Auth.Environment
+ $script:TenantLabel.Text = "Tenant: $($script:scanData.Auth.TenantId) | $($script:scanData.Auth.AccountName) | $envLabel"
+ if ($envLabel -eq 'AzureUSGovernment') {
+ $script:GovTenantButton.Content = "$($script:LockClosed) Gov Tenant"
+ }
+ else {
+ $script:TenantButton.Content = "$($script:LockClosed) Commercial Tenant"
+ }
+ }
+ }
+ @{ Label = 'Loading management group hierarchy...'; Pct = 15; Action = {
+ $script:scanData.Hierarchy = Get-TenantHierarchy -TenantId $script:scanData.Auth.TenantId -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Detecting contract type...'; Pct = 25; Action = {
+ $script:scanData.Contract = Get-ContractInfo -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Querying cost data...'; Pct = 30; Action = {
+ $script:scanData.Costs = Get-CostData -TenantId $script:scanData.Auth.TenantId -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Querying resource-level costs...'; Pct = 40; Action = {
+ $script:scanData.ResourceCosts = Get-ResourceCosts -TenantId $script:scanData.Auth.TenantId -Subscriptions $script:scanData.Auth.Subscriptions -CostData $script:scanData.Costs
+ }
+ }
+ @{ Label = 'Scanning tag inventory...'; Pct = 50; Action = {
+ $script:scanData.Tags = Get-TagInventory -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Querying cost by tag...'; Pct = 55; Action = {
+ $tagNames = if ($script:scanData.Tags) { $script:scanData.Tags.TagNames } else { @{} }
+ $script:scanData.CostByTag = Get-CostByTag -TenantId $script:scanData.Auth.TenantId -ExistingTags $tagNames -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Querying 6-month cost trend...'; Pct = 60; Action = {
+ $script:scanData.CostTrend = Get-CostTrend -TenantId $script:scanData.Auth.TenantId -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Scanning AHB opportunities...'; Pct = 64; Action = {
+ $script:scanData.AHB = Get-AHBOpportunities -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Scanning commitment utilization...'; Pct = 68; Action = {
+ $agreementType = if ($script:scanData.Contract -and $script:scanData.Contract[0].AgreementType) { $script:scanData.Contract[0].AgreementType } else { '' }
+ $script:scanData.Commitments = Get-CommitmentUtilization -Subscriptions $script:scanData.Auth.Subscriptions -AgreementType $agreementType
+ }
+ }
+ @{ Label = 'Scanning orphaned resources...'; Pct = 70; Action = {
+ $script:scanData.Orphans = Get-OrphanedResources -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Scanning idle VMs...'; Pct = 73; Action = {
+ $script:scanData.IdleVMs = Get-IdleVMs -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Scanning storage tier advice...'; Pct = 75; Action = {
+ $script:scanData.StorageTier = Get-StorageTierAdvice -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Loading reservation advice...'; Pct = 77; Action = {
+ $script:scanData.Reservations = Get-ReservationAdvice -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Loading optimization advice...'; Pct = 80; Action = {
+ $script:scanData.Optimization = Get-OptimizationAdvice -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Querying budget status...'; Pct = 82; Action = {
+ $script:scanData.Budgets = Get-BudgetStatus -Subscriptions $script:scanData.Auth.Subscriptions -CostData $script:scanData.Costs
+ }
+ }
+ @{ Label = 'Querying anomaly alerts...'; Pct = 84; Action = {
+ $script:scanData.AnomalyAlerts = Get-AnomalyAlerts -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Calculating savings realized...'; Pct = 86; Action = {
+ $script:scanData.Savings = Get-SavingsRealized -TenantId $script:scanData.Auth.TenantId -Subscriptions $script:scanData.Auth.Subscriptions -CommitmentData $script:scanData.Commitments
+ }
+ }
+ @{ Label = 'Analyzing tag compliance...'; Pct = 88; Action = {
+ $tagNames = if ($script:scanData.Tags) { $script:scanData.Tags.TagNames } else { @{} }
+ $tagLocs = if ($script:scanData.Tags) { $script:scanData.Tags.TagLocations } else { @{} }
+ $script:scanData.TagRecs = Get-TagRecommendations -ExistingTags $tagNames -TagLocations $tagLocs
+ }
+ }
+ @{ Label = 'Scanning policy assignments...'; Pct = 89; Action = {
+ $script:scanData.PolicyInv = Get-PolicyInventory -TenantId $script:scanData.Auth.TenantId -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Analyzing FinOps policy coverage...'; Pct = 90; Action = {
+ $assignments = if ($script:scanData.PolicyInv) { $script:scanData.PolicyInv.Assignments } else { @() }
+ $script:scanData.PolicyRecs = Get-PolicyRecommendations -ExistingAssignments $assignments
+ }
+ }
+ @{ Label = 'Querying billing structure...'; Pct = 92; Action = {
+ $script:scanData.Billing = Get-BillingStructure -Subscriptions $script:scanData.Auth.Subscriptions
+ }
+ }
+ @{ Label = 'Building dashboard...'; Pct = 96; Action = {
+ try { Populate-OverviewTab } catch { Write-Warning "Populate-OverviewTab failed: $($_.Exception.Message)" }
+ try { Populate-CostTab } catch { Write-Warning "Populate-CostTab failed: $($_.Exception.Message)" }
+ try { Populate-TrendChart } catch { Write-Warning "Populate-TrendChart failed: $($_.Exception.Message)" }
+ try { Populate-AnomalySection } catch { Write-Warning "Populate-AnomalySection failed: $($_.Exception.Message)" }
+ try { Populate-AlertsSection } catch { Write-Warning "Populate-AlertsSection failed: $($_.Exception.Message)" }
+ try { Populate-TagsTab } catch { Write-Warning "Populate-TagsTab failed: $($_.Exception.Message)" }
+ try { Populate-PolicyTab } catch { Write-Warning "Populate-PolicyTab failed: $($_.Exception.Message)" }
+ try { Populate-CommitmentSection } catch { Write-Warning "Populate-CommitmentSection failed: $($_.Exception.Message)" }
+ try { Populate-OrphanedSection } catch { Write-Warning "Populate-OrphanedSection failed: $($_.Exception.Message)" }
+ try { Populate-OptimizationTab } catch { Write-Warning "Populate-OptimizationTab failed: $($_.Exception.Message)" }
+ try { Populate-IdleVMSection } catch { Write-Warning "Populate-IdleVMSection failed: $($_.Exception.Message)" }
+ try { Populate-StorageTierSection } catch { Write-Warning "Populate-StorageTierSection failed: $($_.Exception.Message)" }
+ try { Populate-BudgetSection } catch { Write-Warning "Populate-BudgetSection failed: $($_.Exception.Message)" }
+ try { Populate-BudgetsTab } catch { Write-Warning "Populate-BudgetsTab failed: $($_.Exception.Message)" }
+ try { Populate-Scorecard } catch { Write-Warning "Populate-Scorecard failed: $($_.Exception.Message)" }
+ try { Populate-BillingTab } catch { Write-Warning "Populate-BillingTab failed: $($_.Exception.Message)" }
+ try { Populate-GuidanceTab } catch { Write-Warning "Populate-GuidanceTab failed: $($_.Exception.Message)" }
+ try { Populate-ResourcesTab } catch { Write-Warning "Populate-ResourcesTab failed: $($_.Exception.Message)" }
+ $script:tagDeployScopesLoaded = $false # Reset so scopes reload on next tag deploy
+ $script:policyDeployScopesLoaded = $false # Reset so scopes reload on next policy deploy
+ }
+ }
+ @{ Label = 'Scan complete!'; Pct = 100; Action = {
+ $script:ExportButton.IsEnabled = $true
+ }
+ }
+)
+
+$script:currentStage = 0
+$script:scanTimer = [System.Windows.Threading.DispatcherTimer]::new()
+$script:scanTimer.Interval = [TimeSpan]::FromMilliseconds(50)
+
+$script:scanTimer.Add_Tick({
+ if ($script:currentStage -ge $script:scanStages.Count) {
+ $script:scanTimer.Stop()
+ $script:ScanButton.IsEnabled = $true
+ $script:TenantButton.IsEnabled = $true
+ $script:GovTenantButton.IsEnabled = $true
+ $script:ScanButton.Content = "Re-Scan"
+ return
+ }
+
+ $stage = $script:scanStages[$script:currentStage]
+
+ try {
+ $script:StatusText.Text = $stage.Label
+ $script:ProgressBar.Value = $stage.Pct
+ # Force UI update before running the action
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [action] {}, [System.Windows.Threading.DispatcherPriority]::Background
+ )
+
+ & $stage.Action
+ }
+ catch {
+ Write-Warning "Stage '$($stage.Label)' failed: $($_.Exception.Message)"
+ $script:StatusText.Text = "Warning: $($stage.Label) - $($_.Exception.Message)"
+
+ # If authentication failed, abort the entire scan
+ if (-not $script:scanData.Auth) {
+ $script:scanTimer.Stop()
+ $script:ScanButton.IsEnabled = $true
+ $script:TenantButton.IsEnabled = $true
+ $script:GovTenantButton.IsEnabled = $true
+ $script:ScanButton.Content = "Retry Scan"
+ $script:StatusText.Text = "Scan aborted: $($_.Exception.Message)"
+ $script:ProgressBar.Value = 0
+ return
+ }
+ }
+
+ $script:currentStage++
+ })
+
+###########################################################################
+# EVENT WIRING
+###########################################################################
+
+# Scan Button
+$script:ScanButton.Add_Click({
+ $script:ScanButton.IsEnabled = $false
+ $script:TenantButton.IsEnabled = $false
+ $script:GovTenantButton.IsEnabled = $false
+ $script:ExportButton.IsEnabled = $false
+ $script:costAccessIssue = $null
+ $script:currentStage = 0
+ $script:scanTimer.Start()
+ })
+
+# Lock icon characters (surrogates for PS 5.1 compat)
+$script:LockOpen = [char]::ConvertFromUtf32(0x1F513) # open lock
+$script:LockClosed = [char]::ConvertFromUtf32(0x1F512) # closed lock
+
+# Choose Commercial Tenant Button
+$script:TenantButton.Add_Click({
+ $script:TenantButton.IsEnabled = $false
+ $script:GovTenantButton.IsEnabled = $false
+ $script:ScanButton.IsEnabled = $false
+ # Show unlocked while choosing
+ $script:TenantButton.Content = "$($script:LockOpen) Commercial Tenant"
+ $script:StatusText.Text = 'Connecting to Azure Commercial...'
+ try {
+ $authResult = @(Initialize-Scanner -Environment 'AzureCloud' -ParentWindow $window)
+ $script:scanData.Auth = $authResult[-1]
+ $envLabel = $script:scanData.Auth.Environment
+ $subCount = $script:scanData.Auth.Subscriptions.Count
+
+ # Let user select which subscriptions to scan
+ $selected = Show-SubscriptionSelector -Subscriptions $script:scanData.Auth.Subscriptions -SkippedSubs $script:scanData.Auth.SkippedSubs -ParentWindow $window
+ $script:scanData.Auth | Add-Member -NotePropertyName Subscriptions -NotePropertyValue @($selected) -Force
+ $subCount = $script:scanData.Auth.Subscriptions.Count
+
+ $script:TenantLabel.Text = "Tenant: $($script:scanData.Auth.TenantId) | $($script:scanData.Auth.AccountName) | $envLabel"
+ $tenantSize = if ($script:scanData.Auth.TenantSize) { " [$($script:scanData.Auth.TenantSize)]" } else { '' }
+ $script:StatusText.Text = "Connected to $envLabel ($subCount subs$tenantSize). Click 'Scan' to begin."
+ # Show locked after successful selection
+ $script:TenantButton.Content = "$($script:LockClosed) Commercial Tenant"
+ }
+ catch {
+ $script:StatusText.Text = "Tenant switch failed: $($_.Exception.Message)"
+ }
+ $script:TenantButton.IsEnabled = $true
+ $script:GovTenantButton.IsEnabled = $true
+ $script:ScanButton.IsEnabled = $true
+ })
+
+# Choose Gov Tenant Button
+$script:GovTenantButton.Add_Click({
+ $script:TenantButton.IsEnabled = $false
+ $script:GovTenantButton.IsEnabled = $false
+ $script:ScanButton.IsEnabled = $false
+ $script:GovTenantButton.Content = "$($script:LockOpen) Gov Tenant"
+ $script:StatusText.Text = 'Connecting to Azure Government...'
+ try {
+ $authResult = @(Initialize-Scanner -Environment 'AzureUSGovernment' -ParentWindow $window)
+ $script:scanData.Auth = $authResult[-1]
+ $envLabel = $script:scanData.Auth.Environment
+ $subCount = $script:scanData.Auth.Subscriptions.Count
+
+ # Let user select which subscriptions to scan
+ $selected = Show-SubscriptionSelector -Subscriptions $script:scanData.Auth.Subscriptions -SkippedSubs $script:scanData.Auth.SkippedSubs -ParentWindow $window
+ $script:scanData.Auth | Add-Member -NotePropertyName Subscriptions -NotePropertyValue @($selected) -Force
+ $subCount = $script:scanData.Auth.Subscriptions.Count
+
+ $script:TenantLabel.Text = "Tenant: $($script:scanData.Auth.TenantId) | $($script:scanData.Auth.AccountName) | $envLabel"
+ $tenantSize = if ($script:scanData.Auth.TenantSize) { " [$($script:scanData.Auth.TenantSize)]" } else { '' }
+ $script:StatusText.Text = "Connected to $envLabel ($subCount subs$tenantSize). Click 'Scan' to begin."
+ $script:GovTenantButton.Content = "$($script:LockClosed) Gov Tenant"
+ }
+ catch {
+ $script:StatusText.Text = "Gov tenant switch failed: $($_.Exception.Message)"
+ }
+ $script:TenantButton.IsEnabled = $true
+ $script:GovTenantButton.IsEnabled = $true
+ $script:ScanButton.IsEnabled = $true
+ })
+
+# Export Button — show export format chooser dialog
+$script:ExportButton.Add_Click({
+ Show-ExportDialog
+ })
+
+# Budget Tab - Subscription Selector
+$script:BudgetSubSelector.Add_SelectionChanged({
+ Update-BudgetDetailView
+ })
+
+# Budget Tab - Deploy Button
+$script:BudgetDeployButton.Add_Click({
+ Deploy-BudgetFromTab
+ })
+
+# Budget Tab - Cancel Button
+$script:BudgetDeployCancelButton.Add_Click({
+ $script:BudgetDeployNameInput.Text = 'default-budget'
+ $script:BudgetDeployAmountInput.Text = '1000'
+ $script:BudgetDeployEmailInput.Text = ''
+ $script:BudgetThreshold1.Text = ''
+ $script:BudgetThreshold2.Text = ''
+ $script:BudgetThreshold3.Text = ''
+ $script:BudgetThreshold4.Text = ''
+ $script:BudgetDeployStatus.Text = ''
+ })
+
+# Budget Policy - Deploy Button
+$script:BudgetPolicyDeployButton.Add_Click({
+ Deploy-BudgetPolicyFromTab
+ })
+
+# Budget Policy - Cancel Button
+$script:BudgetPolicyCancelButton.Add_Click({
+ $script:BudgetPolicyStatus.Text = ''
+ })
+
+# Tag Selector (Cost Analysis tab)
+$script:TagSelector.Add_SelectionChanged({
+ $selectedTag = $script:TagSelector.SelectedItem
+ if (-not $selectedTag -or -not $script:scanData.CostByTag) { return }
+
+ $data = $script:scanData.CostByTag.CostByTag
+ $tf = $script:scanData.CostByTag.UsedTimeframe
+ $costLabel = if ($tf -eq 'Custom') { 'Cost (Last Month)' } else { 'Cost (MTD)' }
+
+ if ($data.ContainsKey($selectedTag) -and $data[$selectedTag].Count -gt 0) {
+ $tfNote = if ($tf -eq 'Custom') { ' (showing last month - current month data still processing)' } else { '' }
+ $script:NoTagsLabel.Text = $tfNote
+ $rows = $data[$selectedTag] | ForEach-Object {
+ [PSCustomObject]@{
+ 'Tag Value' = $_.TagValue
+ $costLabel = $_.Cost.ToString('N2')
+ 'Currency' = $_.Currency
+ }
+ }
+ $script:CostByTagGrid.ItemsSource = @($rows)
+ }
+ else {
+ $script:CostByTagGrid.ItemsSource = @()
+ $script:NoTagsLabel.Text = "[!] No cost data returned for tag '$selectedTag'. The tag exists on resources but the Cost Management API did not return cost allocations. This can happen if the tagged resources have zero spend this month or if cost data is still processing."
+ }
+ })
+
+# Tag Deploy Button (handles Add, Remove, and Custom modes)
+$script:TagDeployButton.Add_Click({
+ $tagName = $script:tagDeployCurrentTag
+
+ # In custom mode, read tag name from the input
+ if ($script:tagCustomMode) {
+ $tagName = $script:TagNameInput.Text.Trim()
+ if ([string]::IsNullOrWhiteSpace($tagName)) {
+ $script:TagDeployStatus.Text = 'Please enter a tag name.'
+ return
+ }
+ $script:tagDeployCurrentTag = $tagName
+ }
+
+ $selectedIdx = $script:TagScopeSelector.SelectedIndex
+
+ if (-not $tagName) {
+ $script:TagDeployStatus.Text = 'No tag selected.'
+ return
+ }
+ if ($selectedIdx -lt 0) {
+ $script:TagDeployStatus.Text = 'Please select a scope.'
+ return
+ }
+
+ # Determine target scopes — single scope or mass removal (all scopes for a subscription)
+ $allCount = if ($script:tagRemoveMode -and $script:tagRemoveAllEntries) { $script:tagRemoveAllEntries.Count } else { 0 }
+ $massRemove = $script:tagRemoveMode -and ($selectedIdx -lt $allCount)
+
+ if ($massRemove) {
+ # Mass remove: gather the sub + all its RGs from the loaded scopes
+ $selectedAll = $script:tagRemoveAllEntries[$selectedIdx]
+ $targetScopes = @($script:tagDeployScopes | Where-Object { $_.Scope -like "/subscriptions/$($selectedAll.SubId)*" })
+ }
+ else {
+ # Single scope: adjust index if in remove mode (offset by allCount)
+ $adjustedIdx = if ($script:tagRemoveMode) { $selectedIdx - $allCount } else { $selectedIdx }
+ if ($adjustedIdx -lt 0 -or $adjustedIdx -ge $script:tagDeployScopes.Count) {
+ $script:TagDeployStatus.Text = 'Please select a scope.'
+ return
+ }
+ $targetScopes = @($script:tagDeployScopes[$adjustedIdx])
+ }
+
+ $scope = $targetScopes[0].Scope
+ $script:TagDeployButton.IsEnabled = $false
+
+ if ($script:tagRemoveMode) {
+ # REMOVE TAG (single or mass)
+ $valueFilter = $script:TagValueInput.Text.Trim()
+ $filterLabel = if ($valueFilter) { " (value='$valueFilter')" } else { '' }
+ $script:TagDeployStatus.Text = if ($massRemove) { "Removing$filterLabel from sub, RGs, and resources..." } else { "Removing$filterLabel..." }
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.Brushes]::Gray
+
+ try {
+ $token = Get-PlainAccessToken
+ }
+ catch {
+ $script:TagDeployStatus.Text = "Failed: Could not get access token - $($_.Exception.Message)"
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ $script:TagDeployButton.IsEnabled = $true
+ return
+ }
+
+ $allScopes = $targetScopes | ForEach-Object { $_.Scope }
+ $subId = if ($massRemove) { $script:tagRemoveAllEntries[$selectedIdx].SubId } else { '' }
+
+ $rs = [runspacefactory]::CreateRunspace()
+ $rs.Open()
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ [void]$ps.AddScript({
+ param($deployScopeList, $deployTagName, $deployToken, $massMode, $subscriptionId, $valueFilter)
+ $successCount = 0
+ $failCount = 0
+ $failMsg = ''
+ $baseUri = 'https://management.azure.com'
+ $hdrs = @{ 'Authorization' = "Bearer $deployToken"; 'Content-Type' = 'application/json' }
+
+ # If mass mode, find individual resources via Resource Graph (with pagination)
+ if ($massMode -and $subscriptionId) {
+ try {
+ # Escape single quotes to prevent KQL injection
+ $safeTagName = $deployTagName -replace "'", "\\'"
+ $safeValueFilter = if ($valueFilter) { $valueFilter -replace "'", "\\'" } else { $null }
+ # Use case-insensitive tag lookup: enumerate tag keys and compare with tolower()
+ if ($safeValueFilter) {
+ $tagFilter = "| mv-expand bagexpansion=array tkeys = bag_keys(tags) | where tolower(tostring(tkeys)) == tolower('$safeTagName') and tags[tostring(tkeys)] == '$safeValueFilter'"
+ }
+ else {
+ $tagFilter = "| mv-expand bagexpansion=array tkeys = bag_keys(tags) | where tolower(tostring(tkeys)) == tolower('$safeTagName')"
+ }
+ # Query both resources and resourcecontainers (sub/RG-level tags)
+ $query = "resources $tagFilter | project id | union (resourcecontainers $tagFilter | project id)"
+ $skipToken = $null
+ do {
+ $rgBody = @{
+ subscriptions = @($subscriptionId)
+ query = $query
+ options = @{ '$top' = 1000 }
+ }
+ if ($skipToken) { $rgBody.options['$skipToken'] = $skipToken }
+ $rgBodyJson = $rgBody | ConvertTo-Json -Depth 5
+ $rgUri = "$baseUri/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01"
+ $rgResp = Invoke-WebRequest -Uri $rgUri -Method Post -Body $rgBodyJson -Headers $hdrs `
+ -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
+ $rgData = ($rgResp.Content | ConvertFrom-Json)
+ if ($rgData.data) {
+ foreach ($row in $rgData.data) {
+ if ($row.id -and ($row.id -notin $deployScopeList)) {
+ $deployScopeList += $row.id
+ }
+ }
+ }
+ $skipToken = $rgData.'$skipToken'
+ } while ($skipToken)
+ }
+ catch {
+ # Resource Graph query failed — continue with sub/RG scopes only
+ }
+
+ # If value filter is set, also filter sub/RG scopes — only remove from those where tag has the specific value
+ if ($valueFilter) {
+ $filteredScopes = @()
+ foreach ($s in $deployScopeList) {
+ try {
+ $tagUri = "$baseUri$s/providers/Microsoft.Resources/tags/default?api-version=2021-04-01"
+ $tagResp = Invoke-WebRequest -Uri $tagUri -Method Get -Headers $hdrs `
+ -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop
+ $tagData = ($tagResp.Content | ConvertFrom-Json)
+ # Case-insensitive tag name lookup
+ $matched = $false
+ if ($tagData.properties.tags) {
+ foreach ($tk in $tagData.properties.tags.PSObject.Properties) {
+ if ($tk.Name -ieq $deployTagName -and $tk.Value -eq $valueFilter) {
+ $matched = $true; break
+ }
+ }
+ }
+ if ($matched) { $filteredScopes += $s }
+ }
+ catch {
+ # Can't read tags — include scope anyway to attempt removal
+ $filteredScopes += $s
+ }
+ }
+ $deployScopeList = $filteredScopes
+ }
+ }
+
+ foreach ($deployScope in $deployScopeList) {
+ # Resolve the actual tag name casing from the resource to ensure exact match
+ $actualTagName = $deployTagName
+ try {
+ $tagCheckUri = "$baseUri$deployScope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01"
+ $tagCheckResp = Invoke-WebRequest -Uri $tagCheckUri -Method Get -Headers $hdrs `
+ -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
+ $tagCheckData = ($tagCheckResp.Content | ConvertFrom-Json)
+ if ($tagCheckData.properties.tags) {
+ foreach ($tk in $tagCheckData.properties.tags.PSObject.Properties) {
+ if ($tk.Name -ieq $deployTagName) {
+ $actualTagName = $tk.Name
+ break
+ }
+ }
+ }
+ }
+ catch {}
+
+ $uri = "$baseUri$deployScope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01"
+ $body = @{
+ operation = 'Delete'
+ properties = @{ tags = @{ $actualTagName = '' } }
+ } | ConvertTo-Json -Depth 5
+ $hdrs = @{ 'Authorization' = "Bearer $deployToken"; 'Content-Type' = 'application/json' }
+ $succeeded = $false
+ $lastErr = $null
+ for ($retryAttempt = 0; $retryAttempt -lt 3; $retryAttempt++) {
+ try {
+ $resp = Invoke-WebRequest -Uri $uri -Method Patch -Body $body -Headers $hdrs `
+ -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop
+ $successCount++
+ $succeeded = $true
+ break
+ }
+ catch {
+ $lastErr = $_
+ $statusCode = 0
+ if ($_.Exception -is [System.Net.WebException] -and $_.Exception.Response) {
+ $statusCode = [int]$_.Exception.Response.StatusCode
+ }
+ if ($statusCode -ge 500 -and $retryAttempt -lt 2) {
+ Start-Sleep -Milliseconds (1000 * ($retryAttempt + 1))
+ continue
+ }
+ }
+ }
+ if (-not $succeeded) {
+ $failCount++
+ $errMsg = $lastErr.Exception.Message
+ if ($lastErr.Exception -is [System.Net.WebException] -and $lastErr.Exception.Response) {
+ try {
+ $sr = [System.IO.StreamReader]::new($lastErr.Exception.Response.GetResponseStream())
+ $errContent = $sr.ReadToEnd(); $sr.Close()
+ $errBody = $errContent | ConvertFrom-Json -ErrorAction SilentlyContinue
+ if ($errBody.error) { $errMsg = $errBody.error.message }
+ }
+ catch {}
+ }
+ # Include the failing resource scope for diagnostics
+ $shortScope = ($deployScope -split '/')[-1]
+ if (-not $failMsg) { $failMsg = "$errMsg (resource: $shortScope)" }
+ }
+ }
+ [PSCustomObject]@{ SuccessCount = $successCount; FailCount = $failCount; FailMsg = $failMsg }
+ }).AddArgument($allScopes).AddArgument($tagName).AddArgument($token).AddArgument($massRemove).AddArgument($subId).AddArgument($valueFilter)
+
+ $asyncResult = $ps.BeginInvoke()
+ $deadline = (Get-Date).AddSeconds(300)
+ while (-not $asyncResult.IsCompleted -and (Get-Date) -lt $deadline) {
+ $frame = [System.Windows.Threading.DispatcherFrame]::new()
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
+ [System.Windows.Threading.DispatcherPriority]::Background,
+ [action] { $frame.Continue = $false }
+ )
+ [System.Windows.Threading.Dispatcher]::PushFrame($frame)
+ Start-Sleep -Milliseconds 100
+ }
+
+ if ($asyncResult.IsCompleted) {
+ try {
+ $results = $ps.EndInvoke($asyncResult)
+ $result = if ($results.Count -gt 0) { $results[0] } else { $null }
+ }
+ catch {
+ $result = [PSCustomObject]@{ SuccessCount = 0; FailCount = 1; FailMsg = $_.Exception.Message }
+ }
+ if ($result -and $result.FailCount -eq 0) {
+ $script:TagDeployStatus.Text = "Removed '$tagName' from $($result.SuccessCount) scope(s)"
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#107C10')
+ $script:actionLog.Add([PSCustomObject]@{ Time = (Get-Date -Format 'HH:mm:ss'); Type = 'Tag Removed'; Detail = "$tagName ($($result.SuccessCount) scopes)" })
+ }
+ elseif ($result -and $result.SuccessCount -gt 0) {
+ $script:TagDeployStatus.Text = "Partial: $($result.SuccessCount) OK, $($result.FailCount) failed - $($result.FailMsg)"
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ $script:actionLog.Add([PSCustomObject]@{ Time = (Get-Date -Format 'HH:mm:ss'); Type = 'Tag Removed (Partial)'; Detail = "$tagName ($($result.SuccessCount) OK, $($result.FailCount) failed)" })
+ }
+ else {
+ $errMsg = if ($result) { $result.FailMsg } else { 'Unknown error' }
+ $script:TagDeployStatus.Text = "Failed: $errMsg"
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ }
+ else {
+ $ps.Stop()
+ $script:TagDeployStatus.Text = 'Failed: Removal timed out'
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ $ps.Dispose()
+ $rs.Close()
+ }
+ else {
+ # ADD TAG
+ $tagValue = $script:TagValueInput.Text.Trim()
+ if ([string]::IsNullOrWhiteSpace($tagValue)) {
+ $script:TagDeployStatus.Text = 'Please enter a tag value.'
+ $script:TagDeployButton.IsEnabled = $true
+ return
+ }
+
+ $script:TagDeployStatus.Text = 'Deploying...'
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.Brushes]::Gray
+
+ try {
+ $token = Get-PlainAccessToken
+ }
+ catch {
+ $script:TagDeployStatus.Text = "Failed: Could not get access token - $($_.Exception.Message)"
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ $script:TagDeployButton.IsEnabled = $true
+ return
+ }
+
+ $rs = [runspacefactory]::CreateRunspace()
+ $rs.Open()
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ [void]$ps.AddScript({
+ param($deployScope, $deployTagName, $deployTagValue, $deployToken)
+ $uri = "https://management.azure.com$deployScope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01"
+ $body = @{
+ operation = 'Merge'
+ properties = @{ tags = @{ $deployTagName = $deployTagValue } }
+ } | ConvertTo-Json -Depth 5
+ $hdrs = @{ 'Authorization' = "Bearer $deployToken"; 'Content-Type' = 'application/json' }
+ try {
+ $resp = Invoke-WebRequest -Uri $uri -Method Patch -Body $body -Headers $hdrs `
+ -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop
+ [PSCustomObject]@{ Success = $true; Message = "Tag '$deployTagName=$deployTagValue' applied" }
+ }
+ catch {
+ $errMsg = $_.Exception.Message
+ if ($_.Exception -is [System.Net.WebException] -and $_.Exception.Response) {
+ try {
+ $sr = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
+ $errContent = $sr.ReadToEnd(); $sr.Close()
+ $errBody = $errContent | ConvertFrom-Json -ErrorAction SilentlyContinue
+ if ($errBody.error) { $errMsg = $errBody.error.message }
+ }
+ catch {}
+ }
+ [PSCustomObject]@{ Success = $false; Message = $errMsg }
+ }
+ }).AddArgument($scope).AddArgument($tagName).AddArgument($tagValue).AddArgument($token)
+
+ $asyncResult = $ps.BeginInvoke()
+ $deadline = (Get-Date).AddSeconds(35)
+ while (-not $asyncResult.IsCompleted -and (Get-Date) -lt $deadline) {
+ $frame = [System.Windows.Threading.DispatcherFrame]::new()
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
+ [System.Windows.Threading.DispatcherPriority]::Background,
+ [action] { $frame.Continue = $false }
+ )
+ [System.Windows.Threading.Dispatcher]::PushFrame($frame)
+ Start-Sleep -Milliseconds 100
+ }
+
+ if ($asyncResult.IsCompleted) {
+ try {
+ $results = $ps.EndInvoke($asyncResult)
+ $result = if ($results.Count -gt 0) { $results[0] } else { $null }
+ }
+ catch {
+ $result = [PSCustomObject]@{ Success = $false; Message = $_.Exception.Message }
+ }
+ if ($result -and $result.Success) {
+ $script:TagDeployStatus.Text = "Deployed: $tagName=$tagValue"
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#107C10')
+ $script:actionLog.Add([PSCustomObject]@{ Time = (Get-Date -Format 'HH:mm:ss'); Type = 'Tag Deployed'; Detail = "$tagName=$tagValue" })
+ }
+ else {
+ $errMsg = if ($result) { $result.Message } else { 'Unknown error' }
+ $script:TagDeployStatus.Text = "Failed: $errMsg"
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ }
+ else {
+ $ps.Stop()
+ $script:TagDeployStatus.Text = 'Failed: Deployment timed out after 30 seconds'
+ $script:TagDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ $ps.Dispose()
+ $rs.Close()
+ }
+
+ $script:TagDeployButton.IsEnabled = $true
+ })
+
+# Tag Deploy Cancel Button
+$script:TagDeployCancelButton.Add_Click({
+ $script:TagDeployPanel.Visibility = 'Collapsed'
+ $script:tagDeployCurrentTag = $null
+ })
+
+# Deploy Custom Tag Button
+$script:CustomTagButton.Add_Click({
+ Show-CustomTagDeployPanel
+ })
+
+# Policy Deploy / Unassign Button (handles both modes)
+$script:PolicyDeployButton.Add_Click({
+ $defId = $script:policyDeployCurrentDefId
+ $displayName = $script:policyDeployCurrentName
+
+ if (-not $defId) {
+ $script:PolicyDeployStatus.Text = 'No policy selected.'
+ return
+ }
+
+ $script:PolicyDeployButton.IsEnabled = $false
+
+ if ($script:policyUnassignMode) {
+ # UNASSIGN MODE
+ $targets = $script:policyUnassignTargets
+ if (-not $targets -or $targets.Count -eq 0) {
+ $script:PolicyDeployStatus.Text = 'No assignment found to remove.'
+ $script:PolicyDeployButton.IsEnabled = $true
+ return
+ }
+
+ $script:PolicyDeployStatus.Text = 'Removing assignment(s)...'
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.Brushes]::Gray
+
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [System.Windows.Threading.DispatcherPriority]::Render, [action] {})
+
+ $successCount = 0
+ $failMsg = ''
+ foreach ($assignment in $targets) {
+ try {
+ $result = Remove-PolicyAssignment -AssignmentId $assignment.AssignmentId
+ if ($result.Success) {
+ $successCount++
+ }
+ else {
+ $failMsg = $result.Message
+ }
+ }
+ catch {
+ $failMsg = $_.Exception.Message
+ }
+ }
+
+ if ($successCount -eq $targets.Count) {
+ $script:PolicyDeployStatus.Text = "Unassigned: $displayName ($successCount assignment(s) removed)"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#107C10')
+ $script:actionLog.Add([PSCustomObject]@{ Time = (Get-Date -Format 'HH:mm:ss'); Type = 'Policy Unassigned'; Detail = "$displayName ($successCount removed)" })
+ }
+ elseif ($successCount -gt 0) {
+ $script:PolicyDeployStatus.Text = "Partial: $successCount of $($targets.Count) removed. Last error: $failMsg"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ else {
+ $script:PolicyDeployStatus.Text = "Failed: $failMsg"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ }
+ else {
+ # DEPLOY MODE
+ $effect = $script:PolicyEffectSelector.SelectedItem
+ $selectedIdx = $script:PolicyScopeSelector.SelectedIndex
+
+ if (-not $effect) {
+ $script:PolicyDeployStatus.Text = 'Please select an effect.'
+ $script:PolicyDeployButton.IsEnabled = $true
+ return
+ }
+ if ($selectedIdx -lt 0 -or $selectedIdx -ge $script:policyDeployScopes.Count) {
+ $script:PolicyDeployStatus.Text = 'Please select a scope.'
+ $script:PolicyDeployButton.IsEnabled = $true
+ return
+ }
+
+ $scope = $script:policyDeployScopes[$selectedIdx].Scope
+
+ # Collect dynamic parameter values
+ $additionalParams = @{}
+ if ($script:policyParamTextBoxes -and $script:policyParamTextBoxes.Count -gt 0) {
+ foreach ($key in $script:policyParamTextBoxes.Keys) {
+ $entry = $script:policyParamTextBoxes[$key]
+ $val = $entry.TextBox.Text.Trim()
+ $paramDef = $entry.Param
+ if ($paramDef.Required -and [string]::IsNullOrWhiteSpace($val)) {
+ $script:PolicyDeployStatus.Text = "Required parameter missing: $($paramDef.Label -replace ' \*$','')"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ $script:PolicyDeployButton.IsEnabled = $true
+ return
+ }
+ if (-not [string]::IsNullOrWhiteSpace($val)) {
+ if ($paramDef.IsArray) {
+ $additionalParams[$key] = @($val -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
+ }
+ else {
+ $additionalParams[$key] = $val
+ }
+ }
+ }
+ }
+
+ $script:PolicyDeployStatus.Text = 'Deploying...'
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.Brushes]::Gray
+
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [System.Windows.Threading.DispatcherPriority]::Render, [action] {})
+
+ try {
+ $result = Deploy-PolicyAssignment -Scope $scope -PolicyDefinitionId $defId -Effect $effect -DisplayName $displayName -AdditionalParameters $additionalParams
+ if ($result.Success) {
+ $script:PolicyDeployStatus.Text = "Deployed: $displayName ($effect)"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#107C10')
+ $script:actionLog.Add([PSCustomObject]@{ Time = (Get-Date -Format 'HH:mm:ss'); Type = 'Policy Deployed'; Detail = "$displayName ($effect)" })
+ if ($effect -in @('DeployIfNotExists', 'Modify')) {
+ $script:lastPolicyAssignmentScope = $scope
+ $script:lastPolicyAssignmentId = "$scope/providers/Microsoft.Authorization/policyAssignments/$($result.AssignmentName)"
+ $script:PolicyRemediateButton.Visibility = 'Visible'
+ }
+ else {
+ $script:PolicyRemediateButton.Visibility = 'Collapsed'
+ }
+ }
+ else {
+ $script:PolicyDeployStatus.Text = "Failed: $($result.Message)"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ $script:PolicyRemediateButton.Visibility = 'Collapsed'
+ }
+ }
+ catch {
+ $script:PolicyDeployStatus.Text = "Failed: $($_.Exception.Message)"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ $script:PolicyRemediateButton.Visibility = 'Collapsed'
+ }
+ }
+ $script:PolicyDeployButton.IsEnabled = $true
+ })
+
+# Policy Remediation Button
+$script:PolicyRemediateButton.Add_Click({
+ if (-not $script:lastPolicyAssignmentId -or -not $script:lastPolicyAssignmentScope) {
+ $script:PolicyDeployStatus.Text = 'No policy assignment to remediate.'
+ return
+ }
+
+ $script:PolicyRemediateButton.IsEnabled = $false
+ $script:PolicyDeployStatus.Text = 'Creating remediation task...'
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.Brushes]::Gray
+
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke(
+ [System.Windows.Threading.DispatcherPriority]::Render, [action] {})
+
+ try {
+ $remResult = Start-PolicyRemediation -Scope $script:lastPolicyAssignmentScope -PolicyAssignmentId $script:lastPolicyAssignmentId
+ if ($remResult.Success) {
+ $script:PolicyDeployStatus.Text = $remResult.Message
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#107C10')
+ }
+ else {
+ $script:PolicyDeployStatus.Text = "Remediation failed: $($remResult.Message)"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ }
+ catch {
+ $script:PolicyDeployStatus.Text = "Remediation error: $($_.Exception.Message)"
+ $script:PolicyDeployStatus.Foreground = [System.Windows.Media.BrushConverter]::new().ConvertFromString('#D83B01')
+ }
+ $script:PolicyRemediateButton.IsEnabled = $true
+ })
+
+# Policy Deploy Cancel Button
+$script:PolicyDeployCancelButton.Add_Click({
+ $script:PolicyDeployPanel.Visibility = 'Collapsed'
+ $script:PolicyRemediateButton.Visibility = 'Collapsed'
+ $script:policyDeployCurrentDefId = $null
+ $script:policyDeployCurrentName = $null
+ $script:policyUnassignMode = $false
+ $script:policyUnassignTargets = @()
+ # Restore visibility of scope/effect/params for next open
+ $script:PolicyScopeSelector.Visibility = 'Visible'
+ $script:PolicyEffectSelector.Visibility = 'Visible'
+ $script:PolicyParamsPanel.Visibility = 'Visible'
+ foreach ($ctrl in @($script:PolicyScopeSelector, $script:PolicyEffectSelector)) {
+ $parent = $ctrl.Parent
+ if ($parent) {
+ $idx = $parent.Children.IndexOf($ctrl)
+ if ($idx -gt 0) { $parent.Children[$idx - 1].Visibility = 'Visible' }
+ }
+ }
+ })
+
+# Tree Selection
+$script:HierarchyTree.Add_SelectedItemChanged({
+ param($s, $e)
+ $selected = $e.NewValue
+ if (-not $selected -or -not $selected.Tag) { return }
+
+ $info = $selected.Tag
+ if ($info.Type -eq 'Sub') {
+ $script:StatusText.Text = "Selected: $($info.Name) ($($info.Id))"
+ }
+ elseif ($info.Type -eq 'MG') {
+ $script:StatusText.Text = "Management Group: $($info.Name)"
+ }
+ })
+
+###########################################################################
+# LAUNCH
+###########################################################################
+Write-Host ""
+Write-Host " ========================================" -ForegroundColor Cyan
+Write-Host " AZURE FINOPS MULTITOOL" -ForegroundColor Cyan
+Write-Host " ========================================" -ForegroundColor Cyan
+Write-Host " Launching GUI..." -ForegroundColor Cyan
+Write-Host ""
+
+$window.ShowDialog() | Out-Null
+
+# Clean up the shared runspace pool
+if ($script:RunspacePool) {
+ $script:RunspacePool.Close()
+ $script:RunspacePool.Dispose()
+}
diff --git a/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 b/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1
new file mode 100644
index 000000000..ac18eb2e1
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1
@@ -0,0 +1,704 @@
+###########################################################################
+# START-MCPSERVER.PS1
+# FINOPS MULTITOOL MCP SERVER (STDIO)
+###########################################################################
+# Purpose: Model Context Protocol server exposing FinOps scan modules
+# as AI-callable tools over JSON-RPC via stdin/stdout.
+# Author: Zac Larsen
+# Date: Created for FinOps Toolkit integration
+#
+# Description:
+# 1. Imports FinOpsMultitool.psm1 (same module used by TUI/GUI)
+# 2. Listens for JSON-RPC messages on stdin
+# 3. Dispatches tool calls to existing Get-* functions
+# 4. Returns structured JSON results on stdout
+#
+# Prerequisites:
+# - PowerShell 7+ (pwsh)
+# - Az.Accounts, Az.Resources, Az.ResourceGraph modules
+# - Active Azure session (Connect-AzAccount)
+#
+# Usage:
+# MCP config (VS Code settings.json or mcp.json):
+# {
+# "mcp": {
+# "servers": {
+# "finops-multitool": {
+# "command": "pwsh",
+# "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"]
+# }
+# }
+# }
+# }
+###########################################################################
+
+$ErrorActionPreference = 'Stop'
+
+# Suppress all Write-Host output — scan modules use Write-Host for TUI
+# display, but MCP must only write JSON-RPC to stdout.
+# Redirect Write-Host to stderr so MCP clients see clean JSON on stdout.
+$PSDefaultParameterValues['Write-Host:InformationAction'] = 'SilentlyContinue'
+
+# Import the module
+$psm1Path = Join-Path $PSScriptRoot 'FinOpsMultitool.psm1'
+if (-not (Test-Path $psm1Path)) {
+ [Console]::Error.WriteLine("ERROR: FinOpsMultitool.psm1 not found at $psm1Path")
+ exit 1
+}
+Import-Module $psm1Path -Force -DisableNameChecking
+
+# =====================================================================
+# MCP PROTOCOL CONSTANTS
+# =====================================================================
+$MCP_VERSION = '2024-11-05'
+$SERVER_NAME = 'finops-multitool'
+$SERVER_VERSION = '1.0.0'
+
+# =====================================================================
+# TOOL DEFINITIONS
+# =====================================================================
+# Each tool maps to a Get-* function in the module. The MCP server
+# handles subscription resolution and parameter binding.
+
+$toolDefinitions = @(
+ @{
+ name = 'scan_orphaned_resources'
+ description = 'Find orphaned Azure resources (unattached disks, NICs, public IPs, NSGs) across subscriptions.'
+ fn = 'Get-OrphanedResources'
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_idle_vms'
+ description = 'Find idle or underutilized VMs (less than 5% average CPU over 14-30 days) with cost impact classification.'
+ fn = 'Get-IdleVMs'
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_storage_tier_advice'
+ description = 'Analyze storage accounts for tier optimization opportunities (Hot to Cool/Cold/Archive).'
+ fn = 'Get-StorageTierAdvice'
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_ahb_opportunities'
+ description = 'Find Windows/SQL VMs and SQL databases not using Azure Hybrid Benefit (up to 40-55% savings).'
+ fn = 'Get-AHBOpportunities'
+ category = 'Optimization'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_tag_inventory'
+ description = 'Inventory all resource tags across subscriptions. Returns tag coverage percentage, tag names, resource counts, and untagged resources.'
+ fn = 'Get-TagInventory'
+ category = 'Governance'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_tag_recommendations'
+ description = 'Analyze existing tags and recommend improvements: missing CAF standard tags, inconsistent casing, similar/duplicate names.'
+ fn = 'Get-TagRecommendations'
+ category = 'Governance'
+ requiresTagInventory = $true
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_policy_inventory'
+ description = 'List all Azure Policy assignments with scope, effect, enforcement mode, and compliance status.'
+ fn = 'Get-PolicyInventory'
+ category = 'Governance'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_policy_recommendations'
+ description = 'Evaluate policy coverage gaps and recommend cost governance policies (tagging, region, SKU restrictions).'
+ fn = 'Get-PolicyRecommendations'
+ category = 'Governance'
+ requiresPolicyInventory = $true
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_cost_data'
+ description = 'Get current month actual and forecasted cost per subscription. Returns spend, forecast, and currency.'
+ fn = 'Get-CostData'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_resource_costs'
+ description = 'Get top resources by cost (actual month-to-date spend) with resource group, type, and forecast.'
+ fn = 'Get-ResourceCosts'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_cost_by_tag'
+ description = 'Break down cost by tag key/value pairs. Shows spend per tag value and identifies untagged spend.'
+ fn = 'Get-CostByTag'
+ category = 'Cost Analysis'
+ requiresTagInventory = $true
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_cost_trend'
+ description = 'Get month-over-month cost trend (last 3-6 months) per subscription to identify spending patterns.'
+ fn = 'Get-CostTrend'
+ category = 'Cost Analysis'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_reservation_advice'
+ description = 'Get Azure Advisor reservation purchase recommendations with estimated annual savings.'
+ fn = 'Get-ReservationAdvice'
+ category = 'Commitments'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_commitment_utilization'
+ description = 'Check utilization rates of existing reservations and savings plans. Identifies underutilized commitments.'
+ fn = 'Get-CommitmentUtilization'
+ category = 'Commitments'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_savings_realized'
+ description = 'Calculate actual savings from reservations, savings plans, and Azure Hybrid Benefit (monthly and annual).'
+ fn = 'Get-SavingsRealized'
+ category = 'Commitments'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_budget_status'
+ description = 'Check budget consumption vs thresholds. Returns budget amounts, actual spend, percentage used, and risk level.'
+ fn = 'Get-BudgetStatus'
+ category = 'Monitoring'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_anomaly_alerts'
+ description = 'Retrieve recent cost anomaly alerts and detection rules.'
+ fn = 'Get-AnomalyAlerts'
+ category = 'Monitoring'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_optimization_advice'
+ description = 'Get Azure Advisor cost optimization recommendations with estimated annual savings per resource.'
+ fn = 'Get-OptimizationAdvice'
+ category = 'Advisor'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_billing_structure'
+ description = 'Get billing account hierarchy and enrollment details (EA, MCA, CSP).'
+ fn = 'Get-BillingStructure'
+ category = 'Account'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'scan_contract_info'
+ description = 'Get agreement type, offer details, currency, and support plan information.'
+ fn = 'Get-ContractInfo'
+ category = 'Account'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' }
+ }
+ }
+ }
+ @{
+ name = 'run_full_scan'
+ description = 'Run all FinOps scan modules (optimization, governance, cost, commitments, monitoring, advisor) and return a comprehensive assessment. This is the most thorough scan — use individual tools for targeted queries.'
+ fn = '_full_scan'
+ category = 'Assessment'
+ inputSchema = @{
+ type = 'object'
+ properties = @{
+ subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' }
+ modules = @{ type = 'array'; items = @{ type = 'string' }; description = 'Optional list of module names to include. Omit to run all.' }
+ }
+ }
+ }
+)
+
+# Permission requirements per tool (same as TUI)
+$permissionMap = @{
+ 'Get-OrphanedResources' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-IdleVMs' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph + Monitor Metrics' }
+ 'Get-StorageTierAdvice' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-AHBOpportunities' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-TagInventory' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-TagRecommendations' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' }
+ 'Get-PolicyInventory' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Manager' }
+ 'Get-PolicyRecommendations' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Manager' }
+ 'Get-CostData' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' }
+ 'Get-ResourceCosts' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' }
+ 'Get-CostByTag' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' }
+ 'Get-CostTrend' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' }
+ 'Get-ReservationAdvice' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Consumption Reservation Recommendations API' }
+ 'Get-CommitmentUtilization' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Consumption Reservation Summaries API' }
+ 'Get-SavingsRealized' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Cost Management Benefit Utilization API' }
+ 'Get-BudgetStatus' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Consumption Budgets API' }
+ 'Get-AnomalyAlerts' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Cost Management Alerts API' }
+ 'Get-OptimizationAdvice' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Advisor API' }
+ 'Get-BillingStructure' = @{ role = 'Billing Reader'; scope = 'Billing Account'; api = 'Billing API' }
+ 'Get-ContractInfo' = @{ role = 'Billing Reader'; scope = 'Billing Account'; api = 'Billing API' }
+}
+
+# =====================================================================
+# RESOURCE DEFINITIONS
+# =====================================================================
+$resourceDefinitions = @(
+ @{
+ uri = 'finops://permissions'
+ name = 'Permission Requirements'
+ description = 'Required Azure RBAC roles for each scan module.'
+ mimeType = 'application/json'
+ }
+ @{
+ uri = 'finops://modules'
+ name = 'Available Scan Modules'
+ description = 'List of all FinOps scan modules with descriptions and categories.'
+ mimeType = 'application/json'
+ }
+)
+
+# =====================================================================
+# HELPER: RESOLVE SUBSCRIPTIONS
+# =====================================================================
+function Resolve-Subscriptions {
+ param([string]$SubscriptionId)
+
+ if ($SubscriptionId) {
+ $sub = Get-AzSubscription -SubscriptionId $SubscriptionId -ErrorAction Stop
+ return @($sub)
+ }
+
+ $subs = @(Get-AzSubscription -ErrorAction Stop | Where-Object { $_.State -eq 'Enabled' })
+ if ($subs.Count -eq 0) { throw 'No enabled subscriptions found. Run Connect-AzAccount first.' }
+ return $subs
+}
+
+# =====================================================================
+# HELPER: INVOKE TOOL
+# =====================================================================
+function Invoke-McpTool {
+ param(
+ [string]$ToolName,
+ [hashtable]$Arguments
+ )
+
+ $toolDef = $toolDefinitions | Where-Object { $_.name -eq $ToolName }
+ if (-not $toolDef) { throw "Unknown tool: $ToolName" }
+
+ $subId = if ($Arguments.subscriptionId) { $Arguments.subscriptionId } else { $null }
+
+ # Full scan is a composite tool
+ if ($toolDef.fn -eq '_full_scan') {
+ return Invoke-FullScan -SubscriptionId $subId -ModuleFilter $Arguments.modules
+ }
+
+ $fn = $toolDef.fn
+ $subs = Resolve-Subscriptions -SubscriptionId $subId
+ $tenantId = (Get-AzContext).Tenant.Id
+
+ # Build parameter set based on what the function accepts
+ $cmdInfo = Get-Command $fn -ErrorAction Stop
+ $params = @{}
+
+ if ($cmdInfo.Parameters.ContainsKey('Subscriptions')) {
+ $params['Subscriptions'] = $subs
+ }
+ if ($cmdInfo.Parameters.ContainsKey('TenantId') -and $tenantId) {
+ $params['TenantId'] = $tenantId
+ }
+
+ # Handle chained dependencies
+ if ($toolDef.requiresTagInventory) {
+ $tagData = Get-TagInventory -Subscriptions $subs
+ if ($fn -eq 'Get-TagRecommendations') {
+ $params = @{ ExistingTags = if ($tagData.TagNames) { $tagData.TagNames } else { @{} } }
+ if ($tagData.TagLocations) { $params['TagLocations'] = $tagData.TagLocations }
+ }
+ elseif ($fn -eq 'Get-CostByTag') {
+ $params['ExistingTags'] = if ($tagData.TagNames) { $tagData.TagNames } else { @{} }
+ }
+ }
+ if ($toolDef.requiresPolicyInventory) {
+ $policyData = Get-PolicyInventory -Subscriptions $subs -TenantId $tenantId
+ $params = @{ ExistingAssignments = if ($policyData.Assignments) { $policyData.Assignments } else { @() } }
+ }
+
+ # Invoke
+ $result = & $fn @params
+
+ # Add permission context to result
+ $permInfo = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null }
+
+ return @{
+ tool = $ToolName
+ module = $fn
+ category = $toolDef.category
+ data = $result
+ permission = $permInfo
+ timestamp = (Get-Date -Format 'o')
+ }
+}
+
+# =====================================================================
+# FULL SCAN (composite tool)
+# =====================================================================
+function Invoke-FullScan {
+ param(
+ [string]$SubscriptionId,
+ [string[]]$ModuleFilter
+ )
+
+ $subs = Resolve-Subscriptions -SubscriptionId $SubscriptionId
+ $tenantId = (Get-AzContext).Tenant.Id
+
+ # Determine which modules to run
+ $modulesToRun = $toolDefinitions | Where-Object { $_.fn -ne '_full_scan' }
+ if ($ModuleFilter -and $ModuleFilter.Count -gt 0) {
+ $modulesToRun = $modulesToRun | Where-Object { $_.name -in $ModuleFilter }
+ }
+
+ $results = @{}
+ $errors = @{}
+
+ # Run Tag Inventory first (other modules depend on it)
+ $tagData = $null
+ $tagTool = $modulesToRun | Where-Object { $_.fn -eq 'Get-TagInventory' }
+ if ($tagTool) {
+ try {
+ $tagData = Get-TagInventory -Subscriptions $subs
+ $results['scan_tag_inventory'] = $tagData
+ }
+ catch { $errors['scan_tag_inventory'] = $_.Exception.Message }
+ }
+
+ # Run Policy Inventory early (policy recommendations depend on it)
+ $policyData = $null
+ $policyTool = $modulesToRun | Where-Object { $_.fn -eq 'Get-PolicyInventory' }
+ if ($policyTool) {
+ try {
+ $params = @{ Subscriptions = $subs }
+ if ($tenantId) { $params['TenantId'] = $tenantId }
+ $policyData = Get-PolicyInventory @params
+ $results['scan_policy_inventory'] = $policyData
+ }
+ catch { $errors['scan_policy_inventory'] = $_.Exception.Message }
+ }
+
+ # Run remaining modules
+ foreach ($tool in $modulesToRun) {
+ if ($tool.fn -in @('Get-TagInventory', 'Get-PolicyInventory')) { continue }
+
+ try {
+ $fn = $tool.fn
+ $cmdInfo = Get-Command $fn -ErrorAction Stop
+ $params = @{}
+
+ if ($cmdInfo.Parameters.ContainsKey('Subscriptions')) { $params['Subscriptions'] = $subs }
+ if ($cmdInfo.Parameters.ContainsKey('TenantId') -and $tenantId) { $params['TenantId'] = $tenantId }
+
+ # Inject dependencies
+ if ($tool.requiresTagInventory -and $tagData) {
+ if ($fn -eq 'Get-TagRecommendations') {
+ $params = @{ ExistingTags = if ($tagData.TagNames) { $tagData.TagNames } else { @{} } }
+ if ($tagData.TagLocations) { $params['TagLocations'] = $tagData.TagLocations }
+ }
+ elseif ($fn -eq 'Get-CostByTag') {
+ $params['ExistingTags'] = if ($tagData.TagNames) { $tagData.TagNames } else { @{} }
+ }
+ }
+ if ($tool.requiresPolicyInventory -and $policyData) {
+ $params = @{ ExistingAssignments = if ($policyData.Assignments) { $policyData.Assignments } else { @() } }
+ }
+
+ $results[$tool.name] = & $fn @params
+ }
+ catch {
+ $errors[$tool.name] = $_.Exception.Message
+ }
+ }
+
+ return @{
+ tool = 'run_full_scan'
+ subscriptions = @($subs | ForEach-Object { @{ id = $_.Id; name = $_.Name } })
+ results = $results
+ errors = $errors
+ modulesRun = $modulesToRun.Count
+ timestamp = (Get-Date -Format 'o')
+ }
+}
+
+# =====================================================================
+# JSON-RPC MESSAGE HANDLING
+# =====================================================================
+function Send-JsonRpc {
+ param([object]$Message)
+ $json = $Message | ConvertTo-Json -Depth 20 -Compress
+ [Console]::Out.WriteLine($json)
+ [Console]::Out.Flush()
+}
+
+function Send-Result {
+ param([int]$Id, [object]$Result)
+ Send-JsonRpc @{ jsonrpc = '2.0'; id = $Id; result = $Result }
+}
+
+function Send-Error {
+ param([int]$Id, [int]$Code, [string]$Message)
+ Send-JsonRpc @{ jsonrpc = '2.0'; id = $Id; error = @{ code = $Code; message = $Message } }
+}
+
+function Handle-Initialize {
+ param([int]$Id)
+ Send-Result -Id $Id -Result @{
+ protocolVersion = $MCP_VERSION
+ capabilities = @{
+ tools = @{ listChanged = $false }
+ resources = @{ subscribe = $false; listChanged = $false }
+ }
+ serverInfo = @{
+ name = $SERVER_NAME
+ version = $SERVER_VERSION
+ }
+ }
+}
+
+function Handle-ToolsList {
+ param([int]$Id)
+ $tools = $toolDefinitions | ForEach-Object {
+ @{
+ name = $_.name
+ description = $_.description
+ inputSchema = $_.inputSchema
+ }
+ }
+ Send-Result -Id $Id -Result @{ tools = @($tools) }
+}
+
+function Handle-ToolsCall {
+ param([int]$Id, [hashtable]$Params)
+ $toolName = $Params.name
+ $arguments = if ($Params.arguments) { $Params.arguments } else { @{} }
+
+ try {
+ $result = Invoke-McpTool -ToolName $toolName -Arguments $arguments
+ $json = $result | ConvertTo-Json -Depth 20 -Compress
+ Send-Result -Id $Id -Result @{
+ content = @(
+ @{ type = 'text'; text = $json }
+ )
+ }
+ }
+ catch {
+ $errMsg = $_.Exception.Message
+ # Include permission hint if available
+ $toolDef = $toolDefinitions | Where-Object { $_.name -eq $toolName }
+ if ($toolDef -and $permissionMap.ContainsKey($toolDef.fn)) {
+ $perm = $permissionMap[$toolDef.fn]
+ $errMsg += " | Required: $($perm.role) at $($perm.scope) scope ($($perm.api))"
+ }
+ Send-Result -Id $Id -Result @{
+ content = @(
+ @{ type = 'text'; text = $errMsg }
+ )
+ isError = $true
+ }
+ }
+}
+
+function Handle-ResourcesList {
+ param([int]$Id)
+ $resources = $resourceDefinitions | ForEach-Object {
+ @{
+ uri = $_.uri
+ name = $_.name
+ description = $_.description
+ mimeType = $_.mimeType
+ }
+ }
+ Send-Result -Id $Id -Result @{ resources = @($resources) }
+}
+
+function Handle-ResourcesRead {
+ param([int]$Id, [hashtable]$Params)
+ $uri = $Params.uri
+
+ switch ($uri) {
+ 'finops://permissions' {
+ $content = $permissionMap | ConvertTo-Json -Depth 5
+ Send-Result -Id $Id -Result @{
+ contents = @(
+ @{ uri = $uri; mimeType = 'application/json'; text = $content }
+ )
+ }
+ }
+ 'finops://modules' {
+ $modules = $toolDefinitions | Where-Object { $_.fn -ne '_full_scan' } | ForEach-Object {
+ @{ name = $_.name; description = $_.description; category = $_.category; function = $_.fn }
+ }
+ $content = $modules | ConvertTo-Json -Depth 5
+ Send-Result -Id $Id -Result @{
+ contents = @(
+ @{ uri = $uri; mimeType = 'application/json'; text = $content }
+ )
+ }
+ }
+ default {
+ Send-Error -Id $Id -Code -32602 -Message "Unknown resource URI: $uri"
+ }
+ }
+}
+
+# =====================================================================
+# MAIN LOOP — Read JSON-RPC from stdin, dispatch, respond
+# =====================================================================
+[Console]::Error.WriteLine("FinOps Multitool MCP Server v$SERVER_VERSION starting...")
+
+# Suppress Write-Host by redirecting the Information stream
+$origInfoPref = $InformationPreference
+$InformationPreference = 'SilentlyContinue'
+
+try {
+ while ($true) {
+ $line = [Console]::In.ReadLine()
+ if ($null -eq $line) { break } # stdin closed
+ $line = $line.Trim()
+ if ($line -eq '') { continue }
+
+ try {
+ $msg = $line | ConvertFrom-Json -AsHashtable -ErrorAction Stop
+ }
+ catch {
+ # Skip malformed JSON
+ [Console]::Error.WriteLine("Malformed JSON-RPC: $line")
+ continue
+ }
+
+ $method = $msg.method
+ $id = $msg.id
+ $params = if ($msg.params) { $msg.params } else { @{} }
+
+ switch ($method) {
+ 'initialize' { Handle-Initialize -Id $id }
+ 'initialized' { <# notification, no response #> }
+ 'tools/list' { Handle-ToolsList -Id $id }
+ 'tools/call' { Handle-ToolsCall -Id $id -Params $params }
+ 'resources/list' { Handle-ResourcesList -Id $id }
+ 'resources/read' { Handle-ResourcesRead -Id $id -Params $params }
+ 'notifications/initialized' { <# notification, no response #> }
+ 'ping' { Send-Result -Id $id -Result @{} }
+ default {
+ if ($null -ne $id) {
+ Send-Error -Id $id -Code -32601 -Message "Method not found: $method"
+ }
+ }
+ }
+ }
+}
+finally {
+ $InformationPreference = $origInfoPref
+ [Console]::Error.WriteLine("FinOps Multitool MCP Server stopped.")
+}
diff --git a/src/powershell/Private/FinOpsMultitool/gui/MainWindow.xaml b/src/powershell/Private/FinOpsMultitool/gui/MainWindow.xaml
new file mode 100644
index 000000000..13fea24df
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/gui/MainWindow.xaml
@@ -0,0 +1,770 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Azure Budgets let you set a spending cap for a subscription and receive email alerts when spend crosses defined thresholds.
+ Budgets do NOT stop spending — they provide visibility so you can act before costs exceed expectations.
+ Best practice: set thresholds at 25%, 50%, 80%, and 100% so stakeholders get early warnings.
+
+
+ Required permissions: Cost Management Contributor or Contributor role on the target subscription.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Deploy an Azure Policy that automatically creates budgets on new subscriptions (DeployIfNotExists)
+ or audits subscriptions that are missing a budget (AuditIfNotExists). Policies enforce governance at scale.
+
+
+ DINE policies require a remediation task for existing subscriptions. Use the button below after deploying.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/powershell/Private/FinOpsMultitool/gui/app.ico b/src/powershell/Private/FinOpsMultitool/gui/app.ico
new file mode 100644
index 000000000..88980bad3
Binary files /dev/null and b/src/powershell/Private/FinOpsMultitool/gui/app.ico differ
diff --git a/src/powershell/Private/FinOpsMultitool/gui/skeleton.pbit b/src/powershell/Private/FinOpsMultitool/gui/skeleton.pbit
new file mode 100644
index 000000000..c915de039
Binary files /dev/null and b/src/powershell/Private/FinOpsMultitool/gui/skeleton.pbit differ
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1
new file mode 100644
index 000000000..a3481d94d
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1
@@ -0,0 +1,212 @@
+###########################################################################
+# DEPLOY-POLICYASSIGNMENT.PS1
+# AZURE FINOPS MULTITOOL - Deploy Azure Policy Assignments
+###########################################################################
+# Purpose: Create a policy assignment at a given scope (management group,
+# subscription, or resource group) for a built-in policy
+# definition with a user-selected effect.
+#
+# Uses ARM REST API PUT to create policy assignments.
+###########################################################################
+
+function Deploy-PolicyAssignment {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$Scope, # /subscriptions/xxx or /subscriptions/xxx/resourceGroups/yyy
+
+ [Parameter(Mandatory)]
+ [string]$PolicyDefinitionId, # Full built-in policy def resource ID
+
+ [Parameter(Mandatory)]
+ [string]$Effect, # Audit, Deny, Disabled, etc.
+
+ [string]$DisplayName = '',
+
+ [hashtable]$AdditionalParameters = @{}
+ )
+
+ # Input validation
+ if ($Scope -notmatch '^/subscriptions/[a-f0-9-]+') {
+ throw "Invalid scope format. Must start with /subscriptions/{guid}."
+ }
+ if ($Effect -notin @('Audit','Deny','Disabled','AuditIfNotExists','DeployIfNotExists','Modify','Append')) {
+ throw "Invalid effect: $Effect. Must be one of: Audit, Deny, Disabled, AuditIfNotExists, DeployIfNotExists, Modify, Append"
+ }
+ $isInitiative = $PolicyDefinitionId -match '/policySetDefinitions/'
+ if ($PolicyDefinitionId -notmatch '^/providers/Microsoft\.Authorization/policy(Set)?Definitions/') {
+ throw "Invalid policy definition ID format."
+ }
+
+ # Generate a unique assignment name (max 128 chars, alphanumeric + hyphens)
+ $defGuid = ($PolicyDefinitionId -split '/')[-1]
+ $scopeHash = [System.BitConverter]::ToString(
+ [System.Security.Cryptography.SHA256]::Create().ComputeHash(
+ [System.Text.Encoding]::UTF8.GetBytes($Scope)
+ )
+ ).Replace('-','').Substring(0,8).ToLower()
+ $assignName = "finops-$scopeHash-$defGuid"
+ if ($assignName.Length -gt 128) { $assignName = $assignName.Substring(0, 128) }
+
+ $assignDisplayName = if ($DisplayName) { "FinOps: $DisplayName" } else { "FinOps Policy Assignment" }
+
+ Write-Host " Deploying policy assignment '$assignDisplayName' to scope: $Scope" -ForegroundColor Cyan
+ Write-Host " Effect: $Effect | Definition: $defGuid" -ForegroundColor Cyan
+
+ # Query the policy definition to discover which parameters it actually accepts
+ $validParamNames = @()
+ try {
+ $defPath = "$($PolicyDefinitionId)?api-version=2021-06-01"
+ $defResp = Invoke-AzRestMethodWithRetry -Path $defPath -Method GET
+ if ($defResp.StatusCode -eq 200) {
+ $defObj = $defResp.Content | ConvertFrom-Json -ErrorAction SilentlyContinue
+ if ($defObj.properties.parameters) {
+ $validParamNames = @($defObj.properties.parameters.PSObject.Properties.Name)
+ Write-Host " Valid parameters: $($validParamNames -join ', ')" -ForegroundColor Gray
+ }
+ }
+ } catch {
+ Write-Host " Could not query policy definition parameters, sending all." -ForegroundColor Yellow
+ }
+
+ # Build parameters - only include params the definition accepts
+ $policyParams = @{}
+ # Include effect only if the definition has an effect parameter
+ if ($validParamNames.Count -eq 0 -or $validParamNames -contains 'effect') {
+ $policyParams['effect'] = @{ value = $Effect }
+ }
+ foreach ($key in $AdditionalParameters.Keys) {
+ if ($validParamNames.Count -eq 0 -or $validParamNames -contains $key) {
+ $policyParams[$key] = @{ value = $AdditionalParameters[$key] }
+ } else {
+ Write-Host " Skipping parameter '$key' - not defined in policy definition." -ForegroundColor Yellow
+ }
+ }
+
+ $body = @{
+ properties = @{
+ displayName = $assignDisplayName
+ description = "Deployed by Azure FinOps Multitool"
+ policyDefinitionId = $PolicyDefinitionId
+ parameters = $policyParams
+ enforcementMode = 'Default'
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $assignPath = "$Scope/providers/Microsoft.Authorization/policyAssignments/$($assignName)?api-version=2022-06-01"
+
+ try {
+ $response = Invoke-AzRestMethodWithRetry -Path $assignPath -Method PUT -Payload $body
+ if ($response.StatusCode -in @(200, 201)) {
+ Write-Host " Policy assignment created successfully." -ForegroundColor Green
+ return [PSCustomObject]@{
+ Success = $true
+ Message = "Policy '$assignDisplayName' assigned with effect '$Effect' to $Scope"
+ StatusCode = $response.StatusCode
+ AssignmentName = $assignName
+ }
+ } else {
+ $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" }
+ Write-Warning " Policy assignment failed: $errMsg"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = $response.StatusCode
+ }
+ }
+ } catch {
+ Write-Warning " Policy assignment error: $($_.Exception.Message)"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $_.Exception.Message
+ StatusCode = 0
+ }
+ }
+}
+
+function Remove-PolicyAssignment {
+ <#
+ .SYNOPSIS
+ Deletes a policy assignment by its full ARM assignment ID.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$AssignmentId # Full ARM resource ID of the assignment
+ )
+
+ Write-Host " Removing policy assignment: $AssignmentId" -ForegroundColor Cyan
+
+ $deletePath = "$($AssignmentId)?api-version=2022-06-01"
+
+ try {
+ $response = Invoke-AzRestMethodWithRetry -Path $deletePath -Method DELETE
+ if ($response.StatusCode -in @(200, 204)) {
+ Write-Host " Policy assignment removed successfully." -ForegroundColor Green
+ return [PSCustomObject]@{
+ Success = $true
+ Message = "Policy assignment removed"
+ StatusCode = $response.StatusCode
+ }
+ } else {
+ $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" }
+ Write-Warning " Policy removal failed: $errMsg"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = $response.StatusCode
+ }
+ }
+ } catch {
+ Write-Warning " Policy removal error: $($_.Exception.Message)"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $_.Exception.Message
+ StatusCode = 0
+ }
+ }
+}
+
+function Get-PolicyScopes {
+ <#
+ .SYNOPSIS
+ Returns available scopes (subscriptions + resource groups) for policy assignment.
+ Identical pattern to Get-TagScopes but for policy deployment.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $scopes = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ foreach ($sub in $Subscriptions) {
+ [void]$scopes.Add([PSCustomObject]@{
+ DisplayName = "[Sub] $($sub.Name)"
+ Scope = "/subscriptions/$($sub.Id)"
+ Type = 'Subscription'
+ })
+
+ try {
+ $rgPath = "/subscriptions/$($sub.Id)/resourcegroups?api-version=2021-04-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $rgPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $rgs = ($resp.Content | ConvertFrom-Json).value
+ foreach ($rg in $rgs) {
+ [void]$scopes.Add([PSCustomObject]@{
+ DisplayName = " [RG] $($sub.Name) / $($rg.name)"
+ Scope = "/subscriptions/$($sub.Id)/resourceGroups/$($rg.name)"
+ Type = 'ResourceGroup'
+ })
+ }
+ }
+ } catch {
+ Write-Warning " Could not list RGs for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+
+ return $scopes
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1
new file mode 100644
index 000000000..df5d9aa6d
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1
@@ -0,0 +1,217 @@
+###########################################################################
+# DEPLOY-RESOURCETAG.PS1
+# AZURE FINOPS MULTITOOL - Deploy Tags to Azure Resources
+###########################################################################
+# Purpose: Apply a tag (name + value) to a subscription, resource group,
+# or individual resource via ARM REST API (PATCH merge).
+# Preserves existing tags -- only adds or updates the target tag.
+###########################################################################
+
+function Deploy-ResourceTag {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$Scope, # Full ARM resource ID (/subscriptions/xxx or /subscriptions/xxx/resourceGroups/yyy or full resource ID)
+
+ [Parameter(Mandatory)]
+ [string]$TagName,
+
+ [Parameter(Mandatory)]
+ [string]$TagValue
+ )
+
+ # Input validation
+ if ($Scope -notmatch '^/subscriptions/[a-f0-9-]+') {
+ throw "Invalid scope format. Must start with /subscriptions/{guid}."
+ }
+ if ($TagName -match '[<>&''"\\]') {
+ throw "Tag name contains invalid characters."
+ }
+ if ($TagValue -match '[<>&''"]' -and $TagValue.Length -gt 256) {
+ throw "Tag value exceeds 256 characters."
+ }
+
+ Write-Host " Deploying tag '$TagName=$TagValue' to scope: $Scope" -ForegroundColor Cyan
+
+ # Use the Tags API to merge (preserves existing tags)
+ $uri = "https://management.azure.com$Scope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01"
+
+ $body = @{
+ operation = 'Merge'
+ properties = @{
+ tags = @{
+ $TagName = $TagValue
+ }
+ }
+ } | ConvertTo-Json -Depth 5
+
+ # Use Invoke-WebRequest with timeout to prevent indefinite hanging
+ # (Invoke-AzRestMethod has no timeout parameter)
+ $token = Get-PlainAccessToken
+ $headers = @{
+ 'Authorization' = "Bearer $token"
+ 'Content-Type' = 'application/json'
+ }
+
+ try {
+ $response = Invoke-WebRequest -Uri $uri -Method PATCH -Body $body -Headers $headers `
+ -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop
+ if ([int]$response.StatusCode -in @(200, 201)) {
+ Write-Host " Tag deployed successfully." -ForegroundColor Green
+ return [PSCustomObject]@{
+ Success = $true
+ Message = "Tag '$TagName=$TagValue' applied to $Scope"
+ StatusCode = [int]$response.StatusCode
+ }
+ } else {
+ $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" }
+ Write-Warning " Tag deployment failed: $errMsg"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = [int]$response.StatusCode
+ }
+ }
+ } catch {
+ $errMsg = $_.Exception.Message
+ $statusCode = 0
+ # Extract error details from HTTP error responses
+ if ($_.Exception -is [System.Net.WebException] -and $_.Exception.Response) {
+ $statusCode = [int]$_.Exception.Response.StatusCode
+ try {
+ $sr = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
+ $errContent = $sr.ReadToEnd(); $sr.Close()
+ $errBody = $errContent | ConvertFrom-Json -ErrorAction SilentlyContinue
+ if ($errBody.error) { $errMsg = $errBody.error.message }
+ } catch {}
+ }
+ $safeMsg = $errMsg -replace 'Bearer [^\s]+', 'Bearer ***REDACTED***'
+ Write-Warning " Tag deployment failed: $safeMsg"
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $safeMsg
+ StatusCode = $statusCode
+ }
+ }
+}
+
+function Remove-ResourceTag {
+ <#
+ .SYNOPSIS
+ Removes a tag from a subscription or resource group via ARM Tags API (DELETE operation).
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$Scope,
+
+ [Parameter(Mandatory)]
+ [string]$TagName
+ )
+
+ # Input validation
+ if ($Scope -notmatch '^/subscriptions/[a-f0-9-]+') {
+ throw "Invalid scope format. Must start with /subscriptions/{guid}."
+ }
+
+ Write-Host " Removing tag '$TagName' from scope: $Scope" -ForegroundColor Cyan
+
+ $uri = "https://management.azure.com$Scope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01"
+
+ $body = @{
+ operation = 'Delete'
+ properties = @{
+ tags = @{
+ $TagName = ''
+ }
+ }
+ } | ConvertTo-Json -Depth 5
+
+ $token = Get-PlainAccessToken
+ $headers = @{
+ 'Authorization' = "Bearer $token"
+ 'Content-Type' = 'application/json'
+ }
+
+ try {
+ $response = Invoke-WebRequest -Uri $uri -Method PATCH -Body $body -Headers $headers `
+ -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop
+ if ([int]$response.StatusCode -in @(200, 201)) {
+ Write-Host " Tag removed successfully." -ForegroundColor Green
+ return [PSCustomObject]@{
+ Success = $true
+ Message = "Tag '$TagName' removed from $Scope"
+ StatusCode = [int]$response.StatusCode
+ }
+ } else {
+ $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue)
+ $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" }
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = [int]$response.StatusCode
+ }
+ }
+ } catch {
+ $errMsg = $_.Exception.Message
+ $statusCode = 0
+ if ($_.Exception -is [System.Net.WebException] -and $_.Exception.Response) {
+ $statusCode = [int]$_.Exception.Response.StatusCode
+ try {
+ $sr = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
+ $errContent = $sr.ReadToEnd(); $sr.Close()
+ $errBody = $errContent | ConvertFrom-Json -ErrorAction SilentlyContinue
+ if ($errBody.error) { $errMsg = $errBody.error.message }
+ } catch {}
+ }
+ return [PSCustomObject]@{
+ Success = $false
+ Message = $errMsg
+ StatusCode = $statusCode
+ }
+ }
+}
+
+function Get-TagScopes {
+ <#
+ .SYNOPSIS
+ Returns available scopes (subscriptions + resource groups) for tag deployment.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $scopes = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ foreach ($sub in $Subscriptions) {
+ # Add subscription itself
+ [void]$scopes.Add([PSCustomObject]@{
+ DisplayName = "[Sub] $($sub.Name)"
+ Scope = "/subscriptions/$($sub.Id)"
+ Type = 'Subscription'
+ })
+
+ # Get resource groups
+ try {
+ $rgPath = "/subscriptions/$($sub.Id)/resourcegroups?api-version=2021-04-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $rgPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $rgs = ($resp.Content | ConvertFrom-Json).value
+ foreach ($rg in $rgs) {
+ [void]$scopes.Add([PSCustomObject]@{
+ DisplayName = " [RG] $($sub.Name) / $($rg.name)"
+ Scope = "/subscriptions/$($sub.Id)/resourceGroups/$($rg.name)"
+ Type = 'ResourceGroup'
+ })
+ }
+ }
+ } catch {
+ Write-Warning " Could not list RGs for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+
+ return $scopes
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1
new file mode 100644
index 000000000..86d6dd227
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1
@@ -0,0 +1,97 @@
+###########################################################################
+# GET-AHBOPPORTUNITIES.PS1
+# AZURE FINOPS MULTITOOL - Azure Hybrid Benefit Gap Detection
+###########################################################################
+# Purpose: Use Resource Graph to find VMs and SQL resources that are NOT
+# using Azure Hybrid Benefit (AHB) but could be. AHB saves up
+# to 85% on Windows Server and SQL Server licensing costs.
+#
+# Eligible resources:
+# - Windows VMs without licenseType = 'Windows_Server'
+# - SQL Server VMs without licenseType = 'AHUB'
+# - SQL Databases/Managed Instances without licenseType = 'BasePrice'
+#
+# Reference: https://learn.microsoft.com/en-us/azure/azure-sql/azure-hybrid-benefit
+###########################################################################
+
+function Get-AHBOpportunities {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+
+ # -- Windows VMs without AHB ----------------------------------------
+ $windowsVMs = @()
+ try {
+ Write-Host " Scanning Windows VMs for AHB eligibility..." -ForegroundColor Cyan
+ $vmQuery = @"
+resources
+| where type == 'microsoft.compute/virtualmachines'
+| where properties.storageProfile.osDisk.osType =~ 'Windows'
+| where isempty(properties.licenseType) or properties.licenseType !~ 'Windows_Server'
+| project name, resourceGroup, subscriptionId, location,
+ vmSize = properties.hardwareProfile.vmSize,
+ currentLicense = coalesce(tostring(properties.licenseType), 'None'),
+ osType = tostring(properties.storageProfile.imageReference.offer)
+| order by subscriptionId asc, name asc
+"@
+ $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000
+ $windowsVMs = if ($result) { @($result.Data) } else { @() }
+ } catch {
+ Write-Warning "Windows VM AHB scan failed: $($_.Exception.Message)"
+ }
+
+ # -- SQL Server VMs without AHB -------------------------------------
+ $sqlVMs = @()
+ try {
+ Write-Host " Scanning SQL Server VMs for AHB eligibility..." -ForegroundColor Cyan
+ $sqlVMQuery = @"
+resources
+| where type == 'microsoft.sqlvirtualmachine/sqlvirtualmachines'
+| where isempty(properties.sqlServerLicenseType) or properties.sqlServerLicenseType !~ 'AHUB'
+| project name, resourceGroup, subscriptionId, location,
+ currentLicense = coalesce(tostring(properties.sqlServerLicenseType), 'None'),
+ sqlEdition = tostring(properties.sqlImageSku)
+| order by subscriptionId asc, name asc
+"@
+ $result = Search-AzGraphSafe -Query $sqlVMQuery -Subscription $subIds -First 1000
+ $sqlVMs = if ($result) { @($result.Data) } else { @() }
+ } catch {
+ Write-Warning "SQL VM AHB scan failed: $($_.Exception.Message)"
+ }
+
+ # -- SQL Databases without AHB --------------------------------------
+ $sqlDBs = @()
+ try {
+ Write-Host " Scanning SQL Databases for AHB eligibility..." -ForegroundColor Cyan
+ $sqlDBQuery = @"
+resources
+| where type == 'microsoft.sql/servers/databases'
+| where sku.tier != 'Free' and name != 'master'
+| where isempty(properties.licenseType) or properties.licenseType !~ 'BasePrice'
+| project name, resourceGroup, subscriptionId, location,
+ currentLicense = coalesce(tostring(properties.licenseType), 'LicenseIncluded'),
+ sku = strcat(tostring(sku.tier), ' / ', tostring(sku.name)),
+ maxSizeGB = tolong(properties.maxSizeBytes) / 1073741824
+| order by subscriptionId asc, name asc
+"@
+ $result = Search-AzGraphSafe -Query $sqlDBQuery -Subscription $subIds -First 1000
+ $sqlDBs = if ($result) { @($result.Data) } else { @() }
+ } catch {
+ Write-Warning "SQL Database AHB scan failed: $($_.Exception.Message)"
+ }
+
+ # -- Summary --------------------------------------------------------
+ $totalOpportunities = $windowsVMs.Count + $sqlVMs.Count + $sqlDBs.Count
+
+ return [PSCustomObject]@{
+ WindowsVMs = $windowsVMs
+ SQLVMs = $sqlVMs
+ SQLDatabases = $sqlDBs
+ TotalOpportunities = $totalOpportunities
+ Summary = "Found $($windowsVMs.Count) Windows VMs, $($sqlVMs.Count) SQL VMs, $($sqlDBs.Count) SQL DBs eligible for AHB"
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1
new file mode 100644
index 000000000..ebe5ad79b
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1
@@ -0,0 +1,138 @@
+###########################################################################
+# GET-ANOMALYALERTS.PS1
+# AZURE FINOPS MULTITOOL - Cost Management Anomaly & Budget Alerts
+###########################################################################
+# Purpose: Query Azure Cost Management for triggered alerts (anomaly,
+# budget, forecast) and configured anomaly alert rules
+# (InsightAlert scheduled actions) across all subscriptions.
+###########################################################################
+
+function Get-AnomalyAlerts {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $subCount = $Subscriptions.Count
+ Write-Host " Querying anomaly & budget alerts ($subCount subs)..." -ForegroundColor Cyan
+
+ $triggeredAlerts = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $configuredRules = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying anomaly alerts ($i/$subCount subs)..."
+ }
+ }
+
+ # -- Triggered Cost Management alerts --
+ try {
+ $alertPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/alerts?api-version=2023-09-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $alertPath -Method GET
+
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $data = $resp.Content | ConvertFrom-Json
+ if ($data.value) {
+ foreach ($alert in $data.value) {
+ $p = $alert.properties
+ $def = if ($p.definition) { $p.definition } else { @{} }
+ $det = if ($p.details) { $p.details } else { @{} }
+
+ $alertType = if ($def.type) { $def.type } else { 'Unknown' }
+ $category = if ($def.category) { $def.category } else { '' }
+ $criteria = if ($def.criteria) { $def.criteria } else { '' }
+ $status = if ($p.status) { $p.status } else { 'Unknown' }
+
+ $amount = if ($det.amount) { [math]::Round([double]$det.amount, 2) } else { 0 }
+ $currentSpend = if ($det.currentSpend) { [math]::Round([double]$det.currentSpend, 2) } else { 0 }
+ $unit = if ($det.unit) { $det.unit } else { 'USD' }
+
+ $contacts = @()
+ if ($det.contactEmails) { $contacts += @($det.contactEmails) }
+ if ($det.contactRoles) { $contacts += @($det.contactRoles) }
+
+ $createdAt = ''
+ if ($p.creationTime) {
+ try { $createdAt = ([datetime]$p.creationTime).ToString('yyyy-MM-dd') } catch { $createdAt = $p.creationTime }
+ }
+
+ [void]$triggeredAlerts.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ AlertName = $alert.name
+ AlertType = $alertType
+ Category = $category
+ Criteria = $criteria
+ Status = $status
+ Amount = $amount
+ CurrentSpend = $currentSpend
+ Unit = $unit
+ Contacts = (($contacts | Select-Object -Unique) -join ', ')
+ CreatedAt = $createdAt
+ })
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Alert query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+
+ # -- Configured anomaly alert rules (InsightAlert scheduled actions) --
+ try {
+ $saPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/scheduledActions?api-version=2023-03-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $saPath -Method GET
+
+ if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) {
+ $data = $resp.Content | ConvertFrom-Json
+ if ($data.value) {
+ $insightActions = @($data.value | Where-Object { $_.kind -eq 'InsightAlert' })
+ foreach ($sa in $insightActions) {
+ $p = if ($sa.properties) { $sa.properties } else { @{} }
+
+ $toEmails = ''
+ if ($p.notification -and $p.notification.to) {
+ $toEmails = ($p.notification.to -join ', ')
+ }
+
+ $nextRun = ''
+ if ($p.nextRunTime) {
+ try { $nextRun = ([datetime]$p.nextRunTime).ToString('yyyy-MM-dd') } catch { $nextRun = $p.nextRunTime }
+ }
+
+ [void]$configuredRules.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ RuleName = $sa.name
+ DisplayName = if ($p.displayName) { $p.displayName } else { $sa.name }
+ Status = if ($p.status) { $p.status } else { 'Unknown' }
+ Scope = if ($p.scope) { $p.scope } else { "/subscriptions/$($sub.Id)" }
+ ToEmails = $toEmails
+ NextRunTime = $nextRun
+ })
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Scheduled action query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+
+ $anomalyCount = @($triggeredAlerts | Where-Object { $_.AlertType -match 'Anomaly' }).Count
+ $activeCount = @($triggeredAlerts | Where-Object { $_.Status -eq 'Active' }).Count
+ $budgetCount = @($triggeredAlerts | Where-Object { $_.AlertType -match 'Budget' }).Count
+
+ return [PSCustomObject]@{
+ TriggeredAlerts = @($triggeredAlerts)
+ ConfiguredRules = @($configuredRules)
+ TotalAlerts = $triggeredAlerts.Count
+ AnomalyAlertCount = $anomalyCount
+ ActiveAlertCount = $activeCount
+ BudgetAlertCount = $budgetCount
+ ConfiguredRuleCount = $configuredRules.Count
+ HasData = ($triggeredAlerts.Count -gt 0 -or $configuredRules.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1
new file mode 100644
index 000000000..da5098263
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1
@@ -0,0 +1,201 @@
+###########################################################################
+# GET-BILLINGSTRUCTURE.PS1
+# AZURE FINOPS MULTITOOL - Billing Profiles, Invoice Sections & Cost Allocation
+###########################################################################
+# Purpose: Retrieve billing account structure (profiles, invoice sections)
+# and any configured cost allocation rules. Requires Billing Reader
+# on the billing account for full data; falls back gracefully.
+###########################################################################
+
+function Get-BillingStructure {
+ [CmdletBinding()]
+ param(
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Querying billing structure..." -ForegroundColor Cyan
+
+ $billingAccounts = @()
+ $billingProfiles = @()
+ $invoiceSections = @()
+ $costAllocationRules = @()
+
+ # -- Resolve billing account IDs linked to scanned subscriptions -----
+ # Query ALL scanned subscriptions (not just 5) to build a complete set
+ # of billing accounts that belong to this tenant/scan scope.
+ $tenantBillingAccountNames = @{}
+ if ($Subscriptions) {
+ foreach ($sub in $Subscriptions) {
+ try {
+ $biPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Billing/billingInfo/default?api-version=2024-04-01"
+ $biResp = Invoke-AzRestMethodWithRetry -Path $biPath -Method GET
+ if ($biResp.StatusCode -eq 200) {
+ $biResult = ($biResp.Content | ConvertFrom-Json)
+ $baId = $biResult.properties.billingAccountId
+ if ($baId) {
+ # Normalize: extract the account name portion after /billingAccounts/
+ $baName = ($baId -replace '(?i).*/billingAccounts/', '').Trim('/')
+ if ($baName) { $tenantBillingAccountNames[$baName] = $true }
+ }
+ }
+ } catch { }
+ }
+ Write-Host " Resolved $($tenantBillingAccountNames.Count) billing account(s) linked to scanned subscriptions." -ForegroundColor Cyan
+ }
+
+ # -- Step 1: Get Billing Accounts -----------------------------------
+ try {
+ $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01"
+ $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET
+ if ($baResp.StatusCode -eq 200) {
+ $baResult = ($baResp.Content | ConvertFrom-Json)
+ if ($baResult.value) {
+ foreach ($ba in $baResult.value) {
+ # Filter to billing accounts associated with scanned subscriptions
+ if ($tenantBillingAccountNames.Count -gt 0 -and -not $tenantBillingAccountNames.ContainsKey($ba.name)) {
+ continue
+ }
+ # If no billing account names resolved at all, skip rather than showing everything
+ if ($tenantBillingAccountNames.Count -eq 0) {
+ Write-Warning " Could not resolve any billing account IDs from subscriptions — skipping billing account: $($ba.properties.displayName)"
+ continue
+ }
+ $props = $ba.properties
+ $billingAccounts += [PSCustomObject]@{
+ AccountId = $ba.name
+ DisplayName = $props.displayName
+ AgreementType = $props.agreementType
+ AccountType = $props.accountType
+ AccountStatus = $props.accountStatus
+ FullId = $ba.id
+ }
+ }
+ }
+ } else {
+ Write-Warning " Billing accounts returned HTTP $($baResp.StatusCode)"
+ }
+ } catch {
+ Write-Warning " Billing accounts query failed: $($_.Exception.Message)"
+ }
+
+ # -- Step 2: Get Billing Profiles (MCA only) ------------------------
+ foreach ($ba in $billingAccounts) {
+ if ($ba.AgreementType -notin @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) {
+ continue
+ }
+ try {
+ $bpPath = "$($ba.FullId)/billingProfiles?api-version=2024-04-01"
+ $bpResp = Invoke-AzRestMethodWithRetry -Path $bpPath -Method GET
+ if ($bpResp.StatusCode -eq 200) {
+ $bpResult = ($bpResp.Content | ConvertFrom-Json)
+ if ($bpResult.value) {
+ foreach ($bp in $bpResult.value) {
+ $bpProps = $bp.properties
+ $billingProfiles += [PSCustomObject]@{
+ ProfileId = $bp.name
+ DisplayName = $bpProps.displayName
+ BillingAccount = $ba.DisplayName
+ Currency = $bpProps.currency
+ InvoiceDay = $bpProps.invoiceDay
+ Status = $bpProps.status
+ FullId = $bp.id
+ }
+
+ # -- Step 3: Invoice Sections per Profile -------
+ try {
+ $isPath = "$($bp.id)/invoiceSections?api-version=2024-04-01"
+ $isResp = Invoke-AzRestMethodWithRetry -Path $isPath -Method GET
+ if ($isResp.StatusCode -eq 200) {
+ $isResult = ($isResp.Content | ConvertFrom-Json)
+ if ($isResult.value) {
+ foreach ($section in $isResult.value) {
+ $sProps = $section.properties
+ $invoiceSections += [PSCustomObject]@{
+ SectionId = $section.name
+ DisplayName = $sProps.displayName
+ BillingProfile = $bpProps.displayName
+ BillingAccount = $ba.DisplayName
+ State = $sProps.state
+ SystemId = $sProps.systemId
+ FullId = $section.id
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Invoice sections query failed for profile $($bpProps.displayName): $($_.Exception.Message)"
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Billing profiles query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Step 4: EA Departments & Enrollment Accounts (EA only) ---------
+ $eaDepartments = @()
+ foreach ($ba in $billingAccounts) {
+ if ($ba.AgreementType -ne 'EnterpriseAgreement') { continue }
+ try {
+ $deptPath = "$($ba.FullId)/departments?api-version=2024-04-01"
+ $deptResp = Invoke-AzRestMethodWithRetry -Path $deptPath -Method GET
+ if ($deptResp.StatusCode -eq 200) {
+ $deptResult = ($deptResp.Content | ConvertFrom-Json)
+ if ($deptResult.value) {
+ foreach ($dept in $deptResult.value) {
+ $dProps = $dept.properties
+ $eaDepartments += [PSCustomObject]@{
+ DepartmentId = $dept.name
+ DisplayName = $dProps.displayName
+ BillingAccount = $ba.DisplayName
+ CostCenter = $dProps.costCenter
+ Status = $dProps.status
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " EA departments query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Step 5: Cost Allocation Rules ----------------------------------
+ foreach ($ba in $billingAccounts) {
+ try {
+ $carPath = "$($ba.FullId)/providers/Microsoft.CostManagement/costAllocationRules?api-version=2023-11-01"
+ $carResp = Invoke-AzRestMethodWithRetry -Path $carPath -Method GET
+ if ($carResp.StatusCode -eq 200) {
+ $carResult = ($carResp.Content | ConvertFrom-Json)
+ if ($carResult.value) {
+ foreach ($rule in $carResult.value) {
+ $rProps = $rule.properties
+ $costAllocationRules += [PSCustomObject]@{
+ RuleName = $rProps.name
+ Description = $rProps.description
+ Status = $rProps.status
+ BillingAccount = $ba.DisplayName
+ SourceCount = if ($rProps.details.sourceResources) { $rProps.details.sourceResources.Count } else { 0 }
+ TargetCount = if ($rProps.details.targetResources) { $rProps.details.targetResources.Count } else { 0 }
+ CreatedDate = $rProps.createdDate
+ UpdatedDate = $rProps.updatedDate
+ }
+ }
+ }
+ } elseif ($carResp.StatusCode -ne 404) {
+ Write-Warning " Cost allocation rules returned HTTP $($carResp.StatusCode)"
+ }
+ } catch {
+ Write-Warning " Cost allocation rules query failed: $($_.Exception.Message)"
+ }
+ }
+
+ return [PSCustomObject]@{
+ BillingAccounts = $billingAccounts
+ BillingProfiles = $billingProfiles
+ InvoiceSections = $invoiceSections
+ EADepartments = $eaDepartments
+ CostAllocationRules = $costAllocationRules
+ HasBillingAccess = ($billingAccounts.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1
new file mode 100644
index 000000000..af653fc24
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1
@@ -0,0 +1,186 @@
+###########################################################################
+# GET-BUDGETSTATUS.PS1
+# AZURE FINOPS MULTITOOL - Budget vs. Actual Comparison
+###########################################################################
+# Purpose: Query Azure Budgets (Consumption API) for each subscription to
+# show configured budget amount vs current spend. Highlights
+# subscriptions at risk of overrun.
+###########################################################################
+
+function Get-BudgetStatus {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ $CostData # Existing cost data keyed by subscription ID
+ )
+
+ # Guard: extract hashtable if pipeline pollution wrapped it in an array
+ if ($CostData -and $CostData -isnot [hashtable]) {
+ $CostData = @($CostData | Where-Object { $_ -is [hashtable] })[-1]
+ }
+ if (-not $CostData) { $CostData = @{} }
+
+ $subCount = $Subscriptions.Count
+ Write-Host " Querying budget status ($subCount subs)..." -ForegroundColor Cyan
+
+ $budgets = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $subsWithBudget = 0
+ $subsWithoutBudget = 0
+ $sampled = $false
+
+ # -- For large tenants, sample first to see if budgets exist --------
+ $subsToQuery = $Subscriptions
+ if ($subCount -gt 50) {
+ $sampleSize = [math]::Min(10, $subCount)
+ Write-Host " Large tenant: sampling $sampleSize of $subCount subs for budgets..." -ForegroundColor Yellow
+ $sampleSubs = $Subscriptions | Select-Object -First $sampleSize
+ $sampleHits = 0
+ foreach ($sub in $sampleSubs) {
+ try {
+ $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $budgets = ($resp.Content | ConvertFrom-Json).value
+ if ($budgets -and $budgets.Count -gt 0) { $sampleHits++ }
+ }
+ } catch { }
+ }
+
+ if ($sampleHits -eq 0) {
+ Write-Host " No budgets found in sample of $sampleSize subs - skipping remaining" -ForegroundColor Yellow
+ $sampled = $true
+ $subsWithoutBudget = $subCount
+ $subsToQuery = @() # Skip the main loop
+ } else {
+ Write-Host " Budgets found in sample ($sampleHits/$sampleSize), querying all $subCount subs..." -ForegroundColor Cyan
+ }
+ }
+
+ $i = 0
+ foreach ($sub in $subsToQuery) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying budgets ($i/$subCount subs)..."
+ }
+ }
+ try {
+ $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET
+
+ if ($resp.StatusCode -eq 200) {
+ $data = ($resp.Content | ConvertFrom-Json)
+ if ($data.value -and $data.value.Count -gt 0) {
+ $subsWithBudget++
+ foreach ($budget in $data.value) {
+ $bp = $budget.properties
+ $amount = [math]::Round([double]$bp.amount, 2)
+ $timeGrain = $bp.timeGrain
+ $category = $bp.category
+
+ # Current spend from our existing cost data
+ $actualSpend = 0
+ $forecast = 0
+ if ($CostData -and $CostData.ContainsKey($sub.Id)) {
+ $actualSpend = [math]::Round($CostData[$sub.Id].Actual, 2)
+ $forecast = [math]::Round($CostData[$sub.Id].Forecast, 2)
+ }
+
+ # Calculate % used
+ $pctUsed = if ($amount -gt 0) { [math]::Round(($actualSpend / $amount) * 100, 1) } else { 0 }
+ $pctForecast = if ($amount -gt 0) { [math]::Round(($forecast / $amount) * 100, 1) } else { 0 }
+
+ # Risk level
+ $risk = if ($pctForecast -gt 100) { 'Over Budget' }
+ elseif ($pctForecast -gt 90) { 'At Risk' }
+ elseif ($pctForecast -gt 75) { 'Watch' }
+ else { 'On Track' }
+
+ # Notification thresholds and contacts
+ $thresholds = @()
+ $contactEmails = @()
+ $contactRoles = @()
+ if ($bp.notifications) {
+ foreach ($notif in $bp.notifications.PSObject.Properties) {
+ $np = $notif.Value
+ $thresholds += "$($np.threshold)% ($($np.operator))"
+ if ($np.contactEmails) { $contactEmails += @($np.contactEmails) }
+ if ($np.contactRoles) { $contactRoles += @($np.contactRoles) }
+ }
+ }
+
+ # Extract tag filters from budget filter property
+ $tagFilters = @()
+ if ($bp.filter -and $bp.filter.tags) {
+ foreach ($tagProp in $bp.filter.tags.PSObject.Properties) {
+ $tagKey = $tagProp.Name
+ $tagVals = @()
+ if ($tagProp.Value -and $tagProp.Value.values) {
+ $tagVals = @($tagProp.Value.values)
+ }
+ $tagFilters += "$tagKey=$($tagVals -join '|')"
+ }
+ }
+ if ($bp.filter -and $bp.filter.dimensions) {
+ foreach ($dimProp in $bp.filter.dimensions.PSObject.Properties) {
+ if ($dimProp.Name -match '^Tag') {
+ $dimName = $dimProp.Name -replace '^Tag', ''
+ $dimVals = if ($dimProp.Value.values) { @($dimProp.Value.values) } else { @() }
+ $tagFilters += "$dimName=$($dimVals -join '|')"
+ }
+ }
+ }
+ $tagFilterStr = $tagFilters -join '; '
+
+ [void]$budgets.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ BudgetName = $budget.name
+ Amount = $amount
+ TimeGrain = $timeGrain
+ Category = $category
+ ActualSpend = $actualSpend
+ Forecast = $forecast
+ PctUsed = $pctUsed
+ PctForecast = $pctForecast
+ Risk = $risk
+ Thresholds = ($thresholds -join ', ')
+ ContactEmails = (($contactEmails | Select-Object -Unique) -join ', ')
+ ContactRoles = (($contactRoles | Select-Object -Unique) -join ', ')
+ TagFilter = $tagFilterStr
+ Currency = if ($CostData -and $CostData.ContainsKey($sub.Id)) { $CostData[$sub.Id].Currency } else { 'USD' }
+ })
+ }
+ } else {
+ $subsWithoutBudget++
+ }
+ } else {
+ $subsWithoutBudget++
+ }
+ } catch {
+ Write-Warning " Budget query failed for $($sub.Name): $($_.Exception.Message)"
+ $subsWithoutBudget++
+ }
+ }
+
+ # Count risk levels
+ $overBudget = @($budgets | Where-Object { $_.Risk -eq 'Over Budget' }).Count
+ $atRisk = @($budgets | Where-Object { $_.Risk -eq 'At Risk' }).Count
+
+ return [PSCustomObject]@{
+ Budgets = @($budgets)
+ TotalBudgets = $budgets.Count
+ SubsWithBudget = $subsWithBudget
+ SubsWithoutBudget = $subsWithoutBudget
+ OverBudgetCount = $overBudget
+ AtRiskCount = $atRisk
+ HasData = ($budgets.Count -gt 0)
+ Sampled = $sampled
+ BudgetCoverage = if ($Subscriptions.Count -gt 0) {
+ [math]::Round(($subsWithBudget / $Subscriptions.Count) * 100, 1)
+ } else { 0 }
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1
new file mode 100644
index 000000000..2548fdfe3
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1
@@ -0,0 +1,274 @@
+###########################################################################
+# GET-COMMITMENTUTILIZATION.PS1
+# AZURE FINOPS MULTITOOL - RI & Savings Plan Utilization
+###########################################################################
+# Purpose: Query existing reservation and savings plan utilization to show
+# how well current commitments are being used. This answers the
+# CFO question: "Are we wasting what we already bought?"
+###########################################################################
+
+function Get-CommitmentUtilization {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string]$AgreementType
+ )
+
+ Write-Host " Querying commitment utilization..." -ForegroundColor Cyan
+
+ $reservations = @()
+ $savingsPlans = @()
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+
+ # -- Step 0 (MCA/MPA): Resolve billing profiles for this tenant -----
+ # Under MCA, reservations and savings plans are scoped to the billing
+ # profile, NOT the subscription. Subscription-level Consumption API
+ # calls return empty for MCA agreements.
+ $billingProfileIds = @()
+ if ($AgreementType -in @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) {
+ Write-Host " MCA/MPA detected — resolving billing profiles..." -ForegroundColor Cyan
+ # Discover billing account IDs linked to scanned subscriptions
+ $tenantBillingAccountIds = @{}
+ foreach ($sub in @($Subscriptions | Select-Object -First 10)) {
+ try {
+ $biPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Billing/billingInfo/default?api-version=2024-04-01"
+ $biResp = Invoke-AzRestMethodWithRetry -Path $biPath -Method GET
+ if ($biResp.StatusCode -eq 200) {
+ $biResult = ($biResp.Content | ConvertFrom-Json)
+ $baId = $biResult.properties.billingAccountId
+ if ($baId) { $tenantBillingAccountIds[$baId] = $true }
+ }
+ } catch { }
+ }
+
+ # Get billing profiles for those accounts
+ try {
+ $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01"
+ $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET
+ if ($baResp.StatusCode -eq 200) {
+ $baResult = ($baResp.Content | ConvertFrom-Json)
+ foreach ($ba in $baResult.value) {
+ if ($tenantBillingAccountIds.Count -gt 0 -and -not $tenantBillingAccountIds.ContainsKey($ba.id)) {
+ # Normalize — try matching on name portion only
+ $baNamePortion = $ba.id -replace '.*/billingAccounts/', ''
+ $matched = $false
+ foreach ($k in $tenantBillingAccountIds.Keys) {
+ $kName = $k -replace '.*/billingAccounts/', ''
+ if ($kName -eq $baNamePortion) { $matched = $true; break }
+ }
+ if (-not $matched) { continue }
+ }
+ if ($ba.properties.agreementType -notin @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { continue }
+ try {
+ $bpPath = "$($ba.id)/billingProfiles?api-version=2024-04-01"
+ $bpResp = Invoke-AzRestMethodWithRetry -Path $bpPath -Method GET
+ if ($bpResp.StatusCode -eq 200) {
+ $bpResult = ($bpResp.Content | ConvertFrom-Json)
+ foreach ($bp in $bpResult.value) { $billingProfileIds += $bp.id }
+ }
+ } catch { }
+ }
+ }
+ } catch {
+ Write-Warning " Billing profile resolution failed: $($_.Exception.Message)"
+ }
+ Write-Host " Found $($billingProfileIds.Count) billing profile(s) for MCA commitment queries." -ForegroundColor Cyan
+ }
+
+ # -- Step 1: Get all reservations and their utilization --------------
+ # For MCA: query at billing-profile scope first
+ if ($billingProfileIds.Count -gt 0) {
+ foreach ($bpId in $billingProfileIds) {
+ try {
+ $summaryPath = "$bpId/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'"
+ $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $data = ($resp.Content | ConvertFrom-Json)
+ if ($data.value) {
+ foreach ($item in $data.value) {
+ $p = $item.properties
+ $reservations += [PSCustomObject]@{
+ ReservationOrderId = $p.reservationOrderId
+ ReservationId = $p.reservationId
+ SkuName = $p.skuName
+ Kind = $p.kind
+ AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1)
+ MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1)
+ MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1)
+ ReservedHours = $p.reservedHours
+ UsedHours = $p.usedHours
+ UsageDate = $p.usageDate
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Reservation query at billing profile scope failed: $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # For EA / fallback: query at subscription scope
+ if ($reservations.Count -eq 0) {
+ try {
+ $summaryPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'"
+ $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $data = ($resp.Content | ConvertFrom-Json)
+ if ($data.value) {
+ foreach ($item in $data.value) {
+ $p = $item.properties
+ $reservations += [PSCustomObject]@{
+ ReservationOrderId = $p.reservationOrderId
+ ReservationId = $p.reservationId
+ SkuName = $p.skuName
+ Kind = $p.kind
+ AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1)
+ MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1)
+ MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1)
+ ReservedHours = $p.reservedHours
+ UsedHours = $p.usedHours
+ UsageDate = $p.usageDate
+ }
+ }
+ break # Got data from one sub, don't repeat
+ }
+ }
+ } catch {
+ Write-Warning " Reservation summaries query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Step 2: Try the Reservation Orders API at billing scope --
+ if ($reservations.Count -eq 0) {
+ try {
+ $roPath = "/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $roPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $data = ($resp.Content | ConvertFrom-Json)
+ if ($data.value) {
+ foreach ($order in $data.value) {
+ $op = $order.properties
+ if ($op.reservations) {
+ foreach ($ri in $op.reservations) {
+ # Get utilization summary for each reservation
+ try {
+ $utilPath = "$($ri.id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'"
+ $utilResp = Invoke-AzRestMethodWithRetry -Path $utilPath -Method GET
+ if ($utilResp.StatusCode -eq 200) {
+ $utilData = ($utilResp.Content | ConvertFrom-Json)
+ if ($utilData.value -and $utilData.value.Count -gt 0) {
+ $latest = $utilData.value | Select-Object -Last 1
+ $up = $latest.properties
+ $reservations += [PSCustomObject]@{
+ ReservationOrderId = $order.name
+ ReservationId = $ri.id.Split('/')[-1]
+ SkuName = $op.displayProvisioningState
+ Kind = $op.billingScopeId
+ AvgUtilization = [math]::Round([double]$up.avgUtilizationPercentage, 1)
+ MinUtilization = [math]::Round([double]$up.minUtilizationPercentage, 1)
+ MaxUtilization = [math]::Round([double]$up.maxUtilizationPercentage, 1)
+ ReservedHours = $up.reservedHours
+ UsedHours = $up.usedHours
+ UsageDate = $up.usageDate
+ }
+ }
+ }
+ } catch { }
+ }
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Reservation orders query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Step 3: Savings Plans utilization via Benefit Utilization Summaries --
+ # For MCA: query at billing-profile scope first
+ if ($billingProfileIds.Count -gt 0 -and $savingsPlans.Count -eq 0) {
+ foreach ($bpId in $billingProfileIds) {
+ try {
+ $spPath = "$bpId/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly"
+ $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET
+ if ($spResp.StatusCode -eq 200) {
+ $spData = ($spResp.Content | ConvertFrom-Json)
+ if ($spData.value) {
+ foreach ($item in $spData.value) {
+ $p = $item.properties
+ if ($p.benefitType -eq 'SavingsPlan') {
+ $savingsPlans += [PSCustomObject]@{
+ BenefitId = $p.benefitOrderId
+ BenefitType = $p.benefitType
+ AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1)
+ UsageDate = $p.usageDate
+ }
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Savings plan query at billing profile scope failed: $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # Fallback: subscription scope (EA, PAYG, etc.)
+ if ($savingsPlans.Count -eq 0) {
+ try {
+ foreach ($sub in $Subscriptions | Select-Object -First 5) {
+ $spPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly"
+ $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET
+ if ($spResp.StatusCode -eq 200) {
+ $spData = ($spResp.Content | ConvertFrom-Json)
+ if ($spData.value) {
+ foreach ($item in $spData.value) {
+ $p = $item.properties
+ if ($p.benefitType -eq 'SavingsPlan') {
+ $savingsPlans += [PSCustomObject]@{
+ BenefitId = $p.benefitOrderId
+ BenefitType = $p.benefitType
+ AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1)
+ UsageDate = $p.usageDate
+ }
+ }
+ }
+ if ($savingsPlans.Count -gt 0) { break }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Savings plan utilization query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Step 4: Calculate summary stats --
+ $riAvgUtil = 0
+ $riCount = $reservations.Count
+ if ($riCount -gt 0) {
+ $riAvgUtil = [math]::Round(($reservations | Measure-Object -Property AvgUtilization -Average).Average, 1)
+ }
+
+ $spAvgUtil = 0
+ $spCount = $savingsPlans.Count
+ if ($spCount -gt 0) {
+ $spAvgUtil = [math]::Round(($savingsPlans | Measure-Object -Property AvgUtilization -Average).Average, 1)
+ }
+
+ $underutilized = @($reservations | Where-Object { $_.AvgUtilization -lt 80 })
+
+ return [PSCustomObject]@{
+ Reservations = $reservations
+ SavingsPlans = $savingsPlans
+ RICount = $riCount
+ SPCount = $spCount
+ RIAvgUtilization = $riAvgUtil
+ SPAvgUtilization = $spAvgUtil
+ UnderutilizedRIs = $underutilized
+ HasData = ($riCount -gt 0 -or $spCount -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1
new file mode 100644
index 000000000..8aae9622d
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1
@@ -0,0 +1,126 @@
+###########################################################################
+# GET-CONTRACTINFO.PS1
+# AZURE FINOPS MULTITOOL - Billing Account & Contract Type Detection
+###########################################################################
+# Purpose: Detect the customer's Azure contract type (EA, MCA, PAYGO, CSP)
+# and return billing account details.
+#
+# Contract Types:
+# EnterpriseAgreement Enterprise Agreement (EA)
+# MicrosoftCustomerAgreement Microsoft Customer Agreement (MCA)
+# MicrosoftOnlineServicesProgram Pay-As-You-Go (PAYGO / MOSP)
+# MicrosoftPartnerAgreement CSP / Partner (MPA)
+#
+# Reference: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/view-all-accounts
+###########################################################################
+
+function Get-ContractInfo {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [object[]]$Subscriptions
+ )
+
+ $inferredAgreement = $null
+ $inferredFriendly = $null
+
+ # -- Step 1: Detect agreement type from subscription quotaId ---------
+ # QuotaId is always scoped to the correct tenant when using passed subs
+ $subsToCheck = if ($Subscriptions) { @($Subscriptions | Select-Object -First 3) } else { @() }
+ if ($subsToCheck.Count -eq 0) {
+ try { $subsToCheck = @(Get-AzSubscription -ErrorAction SilentlyContinue | Select-Object -First 3) } catch { }
+ }
+
+ foreach ($sub in $subsToCheck) {
+ try {
+ $subPath = "/subscriptions/$($sub.Id)?api-version=2022-12-01"
+ $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method GET
+ if ($subResp.StatusCode -eq 200) {
+ $subDetail = ($subResp.Content | ConvertFrom-Json)
+ $quotaId = $subDetail.properties.subscriptionPolicies.quotaId
+
+ $mapped = switch -Regex ($quotaId) {
+ 'EnterpriseAgreement' { @{ Agreement = 'EnterpriseAgreement'; Friendly = 'Enterprise Agreement (EA)' } }
+ 'MCSFree|MSDN|Visual' { @{ Agreement = 'MSDN'; Friendly = 'Visual Studio / MSDN' } }
+ 'PayAsYouGo|PAYG' { @{ Agreement = 'MicrosoftOnlineServicesProgram'; Friendly = 'Pay-As-You-Go (PAYGO)' } }
+ 'Sponsored' { @{ Agreement = 'Sponsored'; Friendly = 'Azure Sponsored' } }
+ 'CSP' { @{ Agreement = 'MicrosoftPartnerAgreement'; Friendly = 'CSP / Partner Agreement' } }
+ 'Internal' { @{ Agreement = 'Internal'; Friendly = 'Microsoft Internal' } }
+ 'MCA' { @{ Agreement = 'MicrosoftCustomerAgreement'; Friendly = 'Microsoft Customer Agreement (MCA)' } }
+ 'FreeTrial' { @{ Agreement = 'FreeTrial'; Friendly = 'Free Trial' } }
+ 'AAD' { @{ Agreement = 'AAD'; Friendly = 'Azure AD Subscription' } }
+ 'MSAZR' { @{ Agreement = 'MicrosoftOnlineServicesProgram'; Friendly = 'Pay-As-You-Go (PAYGO)' } }
+ default { @{ Agreement = $quotaId; Friendly = $quotaId } }
+ }
+
+ if ($mapped) {
+ $inferredAgreement = $mapped.Agreement
+ $inferredFriendly = $mapped.Friendly
+ Write-Host " QuotaId detected: $quotaId -> $inferredFriendly" -ForegroundColor Green
+ break
+ }
+ }
+ } catch { }
+ }
+
+ # -- Step 2: Try billing accounts API, filtered by inferred type -----
+ try {
+ $response = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" -Method GET
+ if (-not $response -or -not $response.Content) { throw "Billing accounts API returned no content (HTTP $($response.StatusCode))" }
+ $result = ($response.Content | ConvertFrom-Json)
+
+ if ($result.value -and $result.value.Count -gt 0) {
+ $matchedAccount = $null
+
+ # If multiple billing accounts and we know the agreement type, filter
+ if ($inferredAgreement -and $result.value.Count -gt 1) {
+ $matchedAccount = $result.value | Where-Object {
+ $_.properties.agreementType -eq $inferredAgreement
+ } | Select-Object -First 1
+ }
+ if (-not $matchedAccount) {
+ # If only one account or no match, use first
+ $matchedAccount = $result.value | Select-Object -First 1
+ }
+
+ $props = $matchedAccount.properties
+ $friendlyType = switch ($props.agreementType) {
+ 'EnterpriseAgreement' { 'Enterprise Agreement (EA)' }
+ 'MicrosoftCustomerAgreement' { 'Microsoft Customer Agreement (MCA)' }
+ 'MicrosoftOnlineServicesProgram' { 'Pay-As-You-Go (PAYGO)' }
+ 'MicrosoftPartnerAgreement' { 'CSP / Partner Agreement (MPA)' }
+ default { $props.agreementType }
+ }
+
+ return @([PSCustomObject]@{
+ AccountName = $props.displayName
+ AccountId = $matchedAccount.name
+ AgreementType = $props.agreementType
+ FriendlyType = $friendlyType
+ AccountStatus = $props.accountStatus
+ Currency = if ($props.soldTo) { $props.soldTo.country } else { 'Unknown' }
+ })
+ }
+ } catch {
+ Write-Warning "Billing account query failed: $($_.Exception.Message)"
+ }
+
+ # -- Step 3: Return quotaId-based inference if billing API failed ----
+ if ($inferredAgreement) {
+ $subName = if ($subsToCheck.Count -gt 0) { $subsToCheck[0].Name } else { 'Unknown' }
+ return @([PSCustomObject]@{
+ AccountName = "Inferred from subscription: $subName"
+ AccountId = if ($subsToCheck.Count -gt 0) { $subsToCheck[0].Id } else { '' }
+ AgreementType = $inferredAgreement
+ FriendlyType = $inferredFriendly
+ AccountStatus = 'Active'
+ Currency = 'Unknown'
+ })
+ }
+
+ return @([PSCustomObject]@{
+ AccountName = 'Unknown'
+ AgreementType = 'Unknown'
+ FriendlyType = 'Could not detect (assign Billing Reader for accurate detection)'
+ })
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1
new file mode 100644
index 000000000..f5764a5e2
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1
@@ -0,0 +1,552 @@
+###########################################################################
+# GET-COSTBYTAG.PS1
+# AZURE FINOPS MULTITOOL - Cost Breakdown by Tag
+###########################################################################
+# Purpose: For each CAF allocation tag (CostCenter, BusinessUnit,
+# etc.), query Cost Management to show how spend distributes
+# across tag values. If no meaningful tags exist, fall back
+# to cost-by-subscription so the user still sees a breakdown.
+#
+# This is the "Understand" pillar - cost allocation and showback.
+###########################################################################
+
+function Get-CostByTag {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [hashtable]$ExistingTags,
+
+ [Parameter()]
+ [object[]]$Subscriptions
+ )
+
+ # Tags we want to break cost down by (in priority order — matches CAF allocation tags)
+ $targetTags = @('CostCenter', 'BusinessUnit', 'ApplicationName', 'WorkloadName', 'OpsTeam', 'Criticality', 'DataClassification')
+
+ # Also check variations
+ $variations = @{
+ 'CostCenter' = @('cost-center', 'costcenter', 'cost_center', 'cc')
+ 'BusinessUnit' = @('bu', 'businessunit', 'business-unit', 'department', 'dept')
+ 'ApplicationName' = @('applicationname', 'application', 'app', 'appname', 'app-name')
+ 'WorkloadName' = @('workloadname', 'workload', 'workload-name', 'workload_name')
+ 'OpsTeam' = @('opsteam', 'ops-team', 'ops_team', 'owner', 'technicalowner')
+ 'Criticality' = @('criticality', 'sla', 'tier', 'importance')
+ 'DataClassification' = @('dataclassification', 'data-classification', 'data_classification', 'classification')
+ }
+
+ $existingKeys = if ($ExistingTags) { $ExistingTags.Keys | ForEach-Object { $_.ToLower() } } else { @() }
+ $tagsToQuery = @()
+
+ foreach ($tag in $targetTags) {
+ # Check exact match first
+ $match = $existingKeys | Where-Object { $_ -eq $tag.ToLower() } | Select-Object -First 1
+ if ($match) {
+ # Find the properly-cased version from existing tags
+ $properCase = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $match } | Select-Object -First 1
+ $tagsToQuery += $properCase
+ continue
+ }
+
+ # Check variations
+ if ($variations.ContainsKey($tag)) {
+ $varMatch = $existingKeys | Where-Object { $_ -in $variations[$tag] } | Select-Object -First 1
+ if ($varMatch) {
+ $properCase = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $varMatch } | Select-Object -First 1
+ $tagsToQuery += $properCase
+ }
+ }
+ }
+
+ # Also include any additional existing tags not already in the list
+ # Sorted by resource coverage (descending) so the most-used tags survive any cap
+ if ($ExistingTags) {
+ $alreadyLower = $tagsToQuery | ForEach-Object { $_.ToLower() }
+ $systemPrefixes = @('hidden-', 'ms-resource-', 'aks-managed-', 'kubernetes.io', 'displayname')
+ # Exact-match system/auto-generated tags (Azure Policy, Monitor, Automanage, etc.)
+ $systemExact = @(
+ 'action', 'automanage', 'alertrulecreatedwithalertsrecommendations',
+ 'createdby', 'createddate', 'createdtime', 'createdon',
+ 'environment-type', 'intune-deployed', 'policyassignmentname',
+ 'statuschangedate', 'vmsize', 'offer', 'publisher', 'sku'
+ )
+ $extras = @()
+ foreach ($key in $ExistingTags.Keys) {
+ if ($key.ToLower() -in $alreadyLower) { continue }
+ $skip = $false
+ if ($key.ToLower() -in $systemExact) { $skip = $true }
+ if (-not $skip) {
+ foreach ($prefix in $systemPrefixes) {
+ if ($key.ToLower().StartsWith($prefix)) { $skip = $true; break }
+ }
+ }
+ if (-not $skip) {
+ $coverage = if ($ExistingTags[$key].TotalResources) { $ExistingTags[$key].TotalResources } else { 0 }
+ $extras += [PSCustomObject]@{ Name = $key; Coverage = $coverage }
+ }
+ }
+ $extras = $extras | Sort-Object Coverage -Descending
+ foreach ($e in $extras) { $tagsToQuery += $e.Name }
+ }
+
+ # Skip tags with very low resource coverage (< 3 resources tagged)
+ # These produce mostly "(untagged)" results and waste API calls
+ if ($ExistingTags -and $tagsToQuery.Count -gt 8) {
+ $cafTags = $tagsToQuery | Select-Object -First ([math]::Min($tagsToQuery.Count, 7))
+ $extraTags = $tagsToQuery | Select-Object -Skip 7
+ $filteredExtras = @()
+ foreach ($t in $extraTags) {
+ $tagInfo = if ($ExistingTags.ContainsKey($t)) { $ExistingTags[$t] } else { $null }
+ $count = if ($tagInfo -and $tagInfo.TotalResources) { $tagInfo.TotalResources } else { 0 }
+ if ($count -ge 3) { $filteredExtras += $t }
+ }
+ $skipped = $tagsToQuery.Count - $cafTags.Count - $filteredExtras.Count
+ $tagsToQuery = $cafTags + $filteredExtras
+ if ($skipped -gt 0) {
+ Write-Host " Skipped $skipped low-coverage tags (< 3 resources) to reduce API calls" -ForegroundColor Yellow
+ }
+ }
+
+ $results = @{}
+ $useMgScope = Test-MgCostScope
+ $mgPath = "/providers/Microsoft.Management/managementGroups/$TenantId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+
+ # Track subs that don't support Tag grouping (HTTP 400 "Invalid dataset grouping")
+ $skipSubs = [System.Collections.Generic.HashSet[string]]::new()
+
+ # Helper: parse Cost Management query response using column headers
+ function Parse-CostRows {
+ param($ResponseContent)
+ $parsed = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $result = ($ResponseContent | ConvertFrom-Json)
+ if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) {
+ return $parsed
+ }
+ # Build column index map from response
+ $cols = $result.properties.columns
+ $costIdx = -1; $tagIdx = -1; $currIdx = -1
+ for ($i = 0; $i -lt $cols.Count; $i++) {
+ $n = $cols[$i].name.ToLower()
+ if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i }
+ elseif ($n -match 'currency|billingcurrency') { $currIdx = $i }
+ elseif ($n -eq 'tagvalue') { $tagIdx = $i }
+ }
+ # Fallback: if TagValue column not found, pick first String column that isn't TagKey or Currency
+ if ($tagIdx -eq -1) {
+ for ($i = 0; $i -lt $cols.Count; $i++) {
+ if ($cols[$i].type -eq 'String' -and $i -ne $currIdx -and $cols[$i].name.ToLower() -ne 'tagkey') { $tagIdx = $i; break }
+ }
+ }
+ # Final positional fallback
+ if ($costIdx -eq -1) { $costIdx = 0 }
+ if ($tagIdx -eq -1) { $tagIdx = if ($cols.Count -ge 4) { 2 } else { 1 } }
+ if ($currIdx -eq -1) { $currIdx = if ($cols.Count -ge 4) { 3 } else { 2 } }
+
+ foreach ($row in $result.properties.rows) {
+ $cost = [math]::Round([double]$row[$costIdx], 2)
+ $value = if ($row[$tagIdx]) { $row[$tagIdx] } else { '(untagged)' }
+ $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' }
+ [void]$parsed.Add([PSCustomObject]@{ TagValue = $value; Cost = $cost; Currency = $currency })
+ }
+ return $parsed
+ }
+
+ # Helper: parse batched TagKey+TagValue response into per-tag results
+ function Parse-BatchedCostRows {
+ param($ResponseContent)
+ $perTag = @{}
+ $result = ($ResponseContent | ConvertFrom-Json)
+ if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) {
+ return $perTag
+ }
+ $cols = $result.properties.columns
+ $costIdx = -1; $keyIdx = -1; $valIdx = -1; $currIdx = -1
+ for ($i = 0; $i -lt $cols.Count; $i++) {
+ $n = $cols[$i].name.ToLower()
+ if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i }
+ elseif ($n -eq 'tagkey') { $keyIdx = $i }
+ elseif ($n -eq 'tagvalue') { $valIdx = $i }
+ elseif ($n -match 'currency|billingcurrency') { $currIdx = $i }
+ }
+ if ($costIdx -eq -1) { $costIdx = 0 }
+ if ($keyIdx -eq -1) { $keyIdx = 1 }
+ if ($valIdx -eq -1) { $valIdx = 2 }
+ if ($currIdx -eq -1) { $currIdx = 3 }
+
+ foreach ($row in $result.properties.rows) {
+ $tagKey = if ($row[$keyIdx]) { $row[$keyIdx] } else { '' }
+ $tagVal = if ($row[$valIdx]) { $row[$valIdx] } else { '(untagged)' }
+ $cost = [math]::Round([double]$row[$costIdx], 2)
+ $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' }
+ if (-not $perTag.ContainsKey($tagKey)) {
+ $perTag[$tagKey] = [System.Collections.Generic.List[PSCustomObject]]::new()
+ }
+ [void]$perTag[$tagKey].Add([PSCustomObject]@{ TagValue = $tagVal; Cost = $cost; Currency = $currency })
+ }
+ return $perTag
+ }
+
+ # Helper: Fire multiple REST calls in parallel using the shared runspace pool.
+ # Each call handles its own 429 retry internally, so pool slots may block
+ # briefly on throttle but other slots continue processing.
+ function Invoke-ParallelRestCalls {
+ param(
+ [array]$Calls, # Array of @{ Path; Body; SubId; SubName }
+ [int]$TimeoutSeconds = 90
+ )
+ $pendingJobs = [System.Collections.Generic.List[hashtable]]::new()
+ foreach ($call in $Calls) {
+ $ps = [powershell]::Create()
+ $ps.RunspacePool = $script:RunspacePool
+ [void]$ps.AddScript({
+ param($path, $payload)
+ for ($attempt = 0; $attempt -le 3; $attempt++) {
+ $params = @{ Path = $path; Method = 'POST'; ErrorAction = 'Stop' }
+ if ($payload) { $params['Payload'] = $payload }
+ try {
+ $r = Invoke-AzRestMethod @params
+ if ($r.StatusCode -ne 429) {
+ $hdrs = @{}
+ if ($r.Headers) { foreach ($k in $r.Headers.Keys) { $hdrs[$k] = $r.Headers[$k] } }
+ return [PSCustomObject]@{ StatusCode = $r.StatusCode; Content = $r.Content; Headers = $hdrs }
+ }
+ # 429 — parse Retry-After or exponential backoff
+ $retryAfter = 10
+ if ($r.Headers -and $r.Headers['Retry-After']) {
+ $parsed = 0
+ if ([int]::TryParse($r.Headers['Retry-After'], [ref]$parsed)) { $retryAfter = [math]::Max($parsed, 5) }
+ }
+ else { $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 60) }
+ Start-Sleep -Seconds $retryAfter
+ }
+ catch {
+ return [PSCustomObject]@{ StatusCode = 0; Content = "{`"error`":{`"message`":`"$($_.Exception.Message)`"}}"; Headers = @{} }
+ }
+ }
+ return [PSCustomObject]@{ StatusCode = 429; Content = '{"error":{"message":"Rate limited after retries"}}'; Headers = @{} }
+ }).AddArgument($call.Path).AddArgument($call.Body)
+ $async = $ps.BeginInvoke()
+ [void]$pendingJobs.Add(@{ PS = $ps; Async = $async; Call = $call; Result = $null })
+ }
+
+ # Poll until all complete or timeout, keeping WPF UI responsive
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ while ((Get-Date) -lt $deadline) {
+ $allDone = $true
+ foreach ($job in $pendingJobs) {
+ if ($null -ne $job.Result) { continue }
+ if ($job.Async.IsCompleted) {
+ try {
+ $raw = $job.PS.EndInvoke($job.Async)
+ $resp = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null }
+ if (-not $resp) { $resp = [PSCustomObject]@{ StatusCode = 0; Content = '{}'; Headers = @{} } }
+ if ($null -eq $resp.Content) { $resp = [PSCustomObject]@{ StatusCode = $resp.StatusCode; Content = '{}'; Headers = @{} } }
+ $job.Result = $resp
+ }
+ catch {
+ $job.Result = [PSCustomObject]@{ StatusCode = 0; Content = '{}'; Headers = @{} }
+ }
+ $job.PS.Dispose()
+ }
+ else { $allDone = $false }
+ }
+ if ($allDone) { break }
+ Wait-WithDispatcher -Milliseconds 50
+ }
+
+ # Cleanup any timed-out jobs
+ foreach ($job in $pendingJobs) {
+ if ($null -eq $job.Result) {
+ try { $job.PS.Stop() } catch { }
+ $job.PS.Dispose()
+ $job.Result = [PSCustomObject]@{ StatusCode = 408; Content = '{"error":{"message":"Timeout"}}'; Headers = @{} }
+ }
+ }
+ return $pendingJobs
+ }
+
+ # -- Strategy 1: Batched query using TagKey + TagValue grouping -----
+ # This uses a single API call to get cost data for ALL tags at once,
+ # instead of one call per tag. Dramatically reduces API calls and 429s.
+ $batchedResults = $null
+ $batchSuccess = $false
+ $usedTimeframe = 'MonthToDate'
+
+ $timeframes = @('MonthToDate', 'Custom')
+ foreach ($tf in $timeframes) {
+ if ($batchSuccess) { break }
+ Write-Host " Querying cost by all tags (batched, $tf)..." -ForegroundColor Cyan
+
+ $bodyObj = @{
+ type = 'ActualCost'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'TagKey' }
+ @{ type = 'Dimension'; name = 'TagValue' }
+ )
+ }
+ }
+ if ($tf -eq 'Custom') {
+ $lastMonthStart = (Get-Date).AddMonths(-1).ToString('yyyy-MM-01')
+ $lastMonthEnd = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd')
+ $bodyObj['timeframe'] = 'Custom'
+ $bodyObj['timePeriod'] = @{ from = $lastMonthStart; to = $lastMonthEnd }
+ }
+ else {
+ $bodyObj['timeframe'] = $tf
+ }
+ $body = $bodyObj | ConvertTo-Json -Depth 10
+
+ if ($useMgScope) {
+ $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $body
+ if ($response.StatusCode -eq 200) {
+ $batchedResults = Parse-BatchedCostRows -ResponseContent $response.Content
+ if ($batchedResults.Count -gt 0) {
+ $batchSuccess = $true
+ $usedTimeframe = $tf
+ Write-Host " Batched query returned $($batchedResults.Count) tag keys via MG scope ($tf)" -ForegroundColor Green
+ }
+ }
+ elseif ($response.StatusCode -in @(401, 403)) {
+ Set-MgCostScopeFailed
+ $useMgScope = $false
+ }
+ }
+
+ # Per-sub fallback for batched query (parallel)
+ if (-not $batchSuccess -and $Subscriptions) {
+ $allBatched = @{}
+ $calls = @()
+ foreach ($sub in $Subscriptions) {
+ if ($skipSubs.Contains($sub.Id)) { continue }
+ $calls += @{
+ Path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ Body = $body
+ SubId = $sub.Id
+ SubName = $sub.Name
+ }
+ }
+ if ($calls.Count -gt 0) {
+ Write-Host " Querying $($calls.Count) subs in parallel (batched $tf)..." -ForegroundColor Cyan
+ $parallelJobs = Invoke-ParallelRestCalls -Calls $calls
+ foreach ($pj in $parallelJobs) {
+ $subResp = $pj.Result
+ $subName = $pj.Call.SubName
+ $subId = $pj.Call.SubId
+ if ($subResp.StatusCode -eq 200) {
+ $subBatch = Parse-BatchedCostRows -ResponseContent $subResp.Content
+ foreach ($key in $subBatch.Keys) {
+ if (-not $allBatched.ContainsKey($key)) {
+ $allBatched[$key] = [System.Collections.Generic.List[PSCustomObject]]::new()
+ }
+ foreach ($r in $subBatch[$key]) { [void]$allBatched[$key].Add($r) }
+ }
+ }
+ elseif ($subResp.StatusCode -eq 400) {
+ $errBody = try { ($subResp.Content | ConvertFrom-Json).error.message } catch { '' }
+ Write-Host " Batched query failed for '$subName' (HTTP 400): $($errBody.Substring(0, [math]::Min(120, $errBody.Length)))" -ForegroundColor Yellow
+ [void]$skipSubs.Add($subId)
+ if ($errBody -match 'AO View Charges') { $script:costAccessIssue = 'EA' }
+ }
+ elseif ($subResp.StatusCode -eq 403) {
+ Write-Host " Batched query forbidden for '$subName' (HTTP 403)" -ForegroundColor Yellow
+ $script:costAccessIssue = 'MCA'
+ }
+ else {
+ Write-Host " Batched query: '$subName' HTTP $($subResp.StatusCode)" -ForegroundColor Yellow
+ }
+ }
+ }
+ if ($allBatched.Count -gt 0) {
+ $batchedResults = $allBatched
+ $batchSuccess = $true
+ $usedTimeframe = $tf
+ Write-Host " Batched per-sub query returned $($allBatched.Count) tag keys ($tf)" -ForegroundColor Green
+ }
+ }
+ }
+
+ # Map batched results to per-tag results, matching against tagsToQuery
+ if ($batchSuccess -and $batchedResults) {
+ $tagsToQueryLower = @{}
+ foreach ($t in $tagsToQuery) { $tagsToQueryLower[$t.ToLower()] = $t }
+
+ foreach ($batchKey in $batchedResults.Keys) {
+ # Match batch key to requested tag (case-insensitive)
+ $matchedTag = $null
+ if ($tagsToQueryLower.ContainsKey($batchKey.ToLower())) {
+ $matchedTag = $tagsToQueryLower[$batchKey.ToLower()]
+ }
+ if (-not $matchedTag) { continue }
+
+ $tagCosts = $batchedResults[$batchKey]
+ # Merge duplicate values
+ $merged = $tagCosts | Group-Object TagValue -CaseSensitive | ForEach-Object {
+ [PSCustomObject]@{
+ TagValue = $_.Name
+ Cost = [math]::Round(($_.Group | Measure-Object -Property Cost -Sum).Sum, 2)
+ Currency = $_.Group[0].Currency
+ }
+ }
+ $results[$matchedTag] = @($merged | Sort-Object Cost -Descending)
+ }
+
+ # Check if any requested tags weren't in batched results
+ $missingTags = @($tagsToQuery | Where-Object { -not $results.ContainsKey($_) })
+ if ($missingTags.Count -gt 0) {
+ Write-Host " $($missingTags.Count) requested tags had no cost data in batched results" -ForegroundColor Yellow
+ }
+ }
+
+ # -- Strategy 2: Per-tag fallback (only if batched query failed) ----
+ # This is the original approach - one API call per tag. Only used when
+ # the TagKey+TagValue grouping is not supported by the subscription type.
+ # Optimized: per-sub queries run in parallel; tag count capped at 5.
+ if (-not $batchSuccess) {
+ # Clear skipSubs from batched query — Dimension/TagKey != Tag grouping
+ $skipSubs.Clear()
+
+ Write-Host " Batched tag query not available, falling back to per-tag queries ($($tagsToQuery.Count) tags)..." -ForegroundColor Yellow
+
+ $tagQueryCount = 0
+ foreach ($tagName in $tagsToQuery) {
+ $tagQueryCount++
+ if ($tagQueryCount -gt 1) {
+ Start-Sleep -Milliseconds 500
+ }
+
+ try {
+ $tagCosts = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $gotData = $false
+ $usedTimeframe = 'MonthToDate'
+
+ foreach ($tf in $timeframes) {
+ if ($gotData) { break }
+
+ Write-Host " Querying cost by tag: $tagName ($tf)..." -ForegroundColor Cyan
+ $bodyObj = @{
+ type = 'ActualCost'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'TagKey'; name = $tagName }
+ )
+ }
+ }
+ if ($tf -eq 'Custom') {
+ $lastMonthStart = (Get-Date).AddMonths(-1).ToString('yyyy-MM-01')
+ $lastMonthEnd = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd')
+ $bodyObj['timeframe'] = 'Custom'
+ $bodyObj['timePeriod'] = @{ from = $lastMonthStart; to = $lastMonthEnd }
+ }
+ else {
+ $bodyObj['timeframe'] = $tf
+ }
+ $body = $bodyObj | ConvertTo-Json -Depth 10
+
+ $tagCosts = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ if ($useMgScope) {
+ $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $body
+ if ($response.StatusCode -in @(401, 403)) {
+ Set-MgCostScopeFailed
+ $useMgScope = $false
+ }
+ elseif ($response.StatusCode -eq 200) {
+ $tagCosts = Parse-CostRows -ResponseContent $response.Content
+ if ($tagCosts.Count -gt 0) {
+ $gotData = $true
+ $usedTimeframe = $tf
+ Write-Host " Found $($tagCosts.Count) tag values via MG scope ($tf)" -ForegroundColor Green
+ }
+ }
+ else {
+ $useMgScope = $false
+ }
+ }
+
+ # Per-subscription fallback — parallel (also runs if MG scope returned no rows)
+ if ((-not $useMgScope -or -not $gotData) -and $Subscriptions) {
+ $tagCosts = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $calls = @()
+ foreach ($sub in $Subscriptions) {
+ if ($skipSubs.Contains($sub.Id)) { continue }
+ $calls += @{
+ Path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ Body = $body
+ SubId = $sub.Id
+ SubName = $sub.Name
+ }
+ }
+ if ($calls.Count -gt 0) {
+ Write-Host " Querying $($calls.Count) subs in parallel for $tagName ($tf)..." -ForegroundColor Cyan
+ $parallelJobs = Invoke-ParallelRestCalls -Calls $calls
+ foreach ($pj in $parallelJobs) {
+ $subResp = $pj.Result
+ if ($subResp.StatusCode -eq 200) {
+ $subRows = Parse-CostRows -ResponseContent $subResp.Content
+ foreach ($r in $subRows) { [void]$tagCosts.Add($r) }
+ }
+ elseif ($subResp.StatusCode -eq 400) {
+ $errBody = try { ($subResp.Content | ConvertFrom-Json).error.message } catch { '' }
+ if ($errBody -match 'Invalid dataset grouping') {
+ [void]$skipSubs.Add($pj.Call.SubId)
+ }
+ elseif ($errBody -match 'AO View Charges') {
+ $script:costAccessIssue = 'EA'
+ }
+ }
+ elseif ($subResp.StatusCode -eq 403) {
+ $script:costAccessIssue = 'MCA'
+ }
+ }
+ }
+
+ # Merge duplicate tag values across subs
+ if ($tagCosts.Count -gt 0) {
+ $merged = $tagCosts | Group-Object TagValue -CaseSensitive | ForEach-Object {
+ [PSCustomObject]@{
+ TagValue = $_.Name
+ Cost = [math]::Round(($_.Group | Measure-Object -Property Cost -Sum).Sum, 2)
+ Currency = $_.Group[0].Currency
+ }
+ }
+ $tagCosts = @($merged)
+ $gotData = $true
+ $usedTimeframe = $tf
+ Write-Host " Found $($tagCosts.Count) tag values via per-sub parallel ($tf)" -ForegroundColor Green
+ }
+ }
+ }
+
+ $results[$tagName] = $tagCosts | Sort-Object Cost -Descending
+ }
+ catch {
+ Write-Warning "Cost-by-tag query for '$tagName' failed: $($_.Exception.Message)"
+ }
+ }
+ } # end per-tag fallback
+
+ # Determine which timeframe was used (for display hint)
+ $usedLastMonth = $false
+ foreach ($tagName in $tagsToQuery) {
+ if ($results.ContainsKey($tagName) -and $results[$tagName].Count -gt 0) { break }
+ }
+
+ return [PSCustomObject]@{
+ TagsQueried = $tagsToQuery
+ CostByTag = $results
+ NoTagsFound = ($tagsToQuery.Count -eq 0)
+ UsedTimeframe = $usedTimeframe
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1
new file mode 100644
index 000000000..9ef79fc55
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1
@@ -0,0 +1,311 @@
+###########################################################################
+# GET-COSTDATA.PS1
+# AZURE FINOPS MULTITOOL - Current & Forecasted Cost Data
+###########################################################################
+# Purpose: Query Cost Management API at the management-group scope to
+# retrieve actual month-to-date spend and forecasted spend for
+# every subscription in a single efficient call.
+#
+# Approach: MG-scope queries avoid N per-subscription calls. We group
+# results by SubscriptionId so costs roll up correctly.
+#
+# Reference: https://learn.microsoft.com/en-us/rest/api/cost-management/query/usage
+###########################################################################
+
+function Get-CostData {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions
+ )
+
+ $costMap = @{}
+
+ # Skip MG-scope if a prior module already detected it's unavailable
+ if (-not (Test-MgCostScope)) {
+ Write-Host " Querying actual costs (per-subscription)..." -ForegroundColor Cyan
+ return Get-CostDataPerSubscription -Subscriptions $Subscriptions
+ }
+
+ # -- Actual Cost (Month-to-Date) ------------------------------------
+ try {
+ Write-Host " Querying actual costs (MG scope)..." -ForegroundColor Cyan
+ $actualBody = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'SubscriptionId' }
+ )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $mgPath = "/providers/Microsoft.Management/managementGroups/$TenantId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $actualBody
+
+ if ($response.StatusCode -in @(401, 403)) {
+ Set-MgCostScopeFailed
+ throw "MG-scope cost query returned HTTP $($response.StatusCode). Falling back to per-subscription."
+ }
+ if ($response.StatusCode -ne 200) {
+ throw "MG-scope cost query returned HTTP $($response.StatusCode). Falling back to per-subscription."
+ }
+
+ $result = ($response.Content | ConvertFrom-Json)
+
+ if ($result.properties.rows) {
+ foreach ($row in $result.properties.rows) {
+ $subId = $row[1]
+ $amount = [math]::Round($row[0], 2)
+ $currency = $row[2]
+
+ if (-not $costMap.ContainsKey($subId)) {
+ $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = $currency }
+ }
+ $costMap[$subId].Actual = $amount
+ $costMap[$subId].Currency = $currency
+ }
+ }
+ }
+ catch {
+ Write-Warning "Actual cost query failed: $($_.Exception.Message)"
+ Write-Warning "Falling back to per-subscription queries."
+ $costMap = Get-CostDataPerSubscription -Subscriptions $Subscriptions
+ return $costMap
+ }
+
+ # -- Forecasted Cost (Current Billing Period) -----------------------
+ # Try MG-scope first, fall back to per-subscription if it fails
+ $forecastSuccess = $false
+ try {
+ Write-Host " Querying forecast costs (MG scope)..." -ForegroundColor Cyan
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+
+ $forecastBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'SubscriptionId' }
+ )
+ }
+ includeActualCost = $true
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $forecastPath = "/providers/Microsoft.Management/managementGroups/$TenantId/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01"
+ $fResponse = Invoke-AzRestMethodWithRetry -Path $forecastPath -Method POST -Payload $forecastBody
+
+ if ($fResponse.StatusCode -ne 200) {
+ throw "Forecast query returned HTTP $($fResponse.StatusCode)"
+ }
+
+ $fResult = ($fResponse.Content | ConvertFrom-Json)
+
+ if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) {
+ # The Forecast API with includeActualCost returns rows that may have
+ # a CostStatus column (Actual/Forecast). Sum all rows per subscription
+ # to get the full-month projected cost.
+ $forecastSums = @{}
+ foreach ($row in $fResult.properties.rows) {
+ $subId = $row[1]
+ $amount = [double]$row[0]
+ if (-not $forecastSums.ContainsKey($subId)) { $forecastSums[$subId] = 0 }
+ $forecastSums[$subId] += $amount
+ }
+ foreach ($subId in $forecastSums.Keys) {
+ if (-not $costMap.ContainsKey($subId)) {
+ $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = 'USD' }
+ }
+ $costMap[$subId].Forecast = [math]::Round($forecastSums[$subId], 2)
+ }
+ $forecastSuccess = $true
+ Write-Host " MG-scope forecast: got data for $($forecastSums.Count) subscriptions" -ForegroundColor Green
+ }
+ else {
+ throw "MG-scope forecast returned 0 rows"
+ }
+ }
+ catch {
+ Write-Warning "MG-scope forecast failed: $($_.Exception.Message)"
+ Write-Host " Falling back to per-subscription forecast queries..." -ForegroundColor Yellow
+ }
+
+ # Per-subscription forecast fallback
+ if (-not $forecastSuccess -and $Subscriptions) {
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+ $subCount = $Subscriptions.Count
+ $i = 0
+ $hitCount = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i % [math]::Max(1, [int]($subCount / 10)) -eq 0) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying forecasts ($i/$subCount subs)..."
+ }
+ }
+ try {
+ $fBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ includeActualCost = $true
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $fResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" -Method POST -Payload $fBody
+ if ($fResp.StatusCode -eq 200) {
+ $fRes = ($fResp.Content | ConvertFrom-Json)
+ if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) {
+ $total = 0
+ foreach ($row in $fRes.properties.rows) { $total += [double]$row[0] }
+ if (-not $costMap.ContainsKey($sub.Id)) {
+ $costMap[$sub.Id] = @{ Actual = 0; Forecast = 0; Currency = 'USD' }
+ }
+ $costMap[$sub.Id].Forecast = [math]::Round($total, 2)
+ $hitCount++
+ }
+ }
+ }
+ catch {
+ # Forecast not available for this sub
+ }
+ }
+ Write-Host " Per-sub forecast: got data for $hitCount of $subCount subscriptions" -ForegroundColor $(if ($hitCount -gt 0) { 'Green' } else { 'Yellow' })
+ }
+
+ # Ensure any subs without forecast data default to actual
+ foreach ($subId in $costMap.Keys) {
+ if ($costMap[$subId].Forecast -eq 0 -and $costMap[$subId].Actual -gt 0) {
+ $costMap[$subId].Forecast = $costMap[$subId].Actual
+ }
+ }
+
+ return $costMap
+}
+
+# -- Fallback: Per-Subscription Cost Queries ----------------------------
+function Get-CostDataPerSubscription {
+ param([object[]]$Subscriptions)
+
+ $costMap = @{}
+ $subCount = $Subscriptions.Count
+ $skipForecast = ($subCount -gt 100) # For very large tenants, skip per-sub forecast to halve API calls
+ if ($skipForecast) {
+ Write-Host " Large tenant ($subCount subs): skipping per-sub forecast to reduce API calls" -ForegroundColor Yellow
+ }
+
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying costs ($i/$subCount subs)..."
+ }
+ }
+ try {
+ $body = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement"
+ $resp = Invoke-AzRestMethodWithRetry -Path "$path/query?api-version=2023-11-01" -Method POST -Payload $body
+
+ $actual = 0; $currency = 'USD'
+ if ($resp.StatusCode -eq 200) {
+ $res = ($resp.Content | ConvertFrom-Json)
+ if ($res.properties.rows -and $res.properties.rows.Count -gt 0) {
+ $actual = [math]::Round($res.properties.rows[0][0], 2)
+ $currency = $res.properties.rows[0][1]
+ }
+ }
+ elseif ($resp.StatusCode -in @(400, 403) -and $resp.Content) {
+ $errMsg = try { ($resp.Content | ConvertFrom-Json).error.message } catch { '' }
+ if ($errMsg -match 'AO View Charges') {
+ $script:costAccessIssue = 'EA'
+ Write-Warning " Cost data disabled for EA account owners. Enable 'AO View Charges' in the EA portal."
+ }
+ elseif ($resp.StatusCode -eq 403) {
+ $script:costAccessIssue = 'MCA'
+ Write-Warning " Cost data access denied. Verify Billing Profile Reader or Cost Management Reader role assignment."
+ }
+ }
+
+ $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency }
+
+ # Per-sub forecast (skipped for large tenants)
+ if (-not $skipForecast) {
+ try {
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+ $fBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ includeActualCost = $true
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $fResp = Invoke-AzRestMethodWithRetry -Path "$path/forecast?api-version=2023-11-01" -Method POST -Payload $fBody
+ if ($fResp.StatusCode -eq 200) {
+ $fRes = ($fResp.Content | ConvertFrom-Json)
+ if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) {
+ $fAmount = [math]::Round($fRes.properties.rows[0][0], 2)
+ $costMap[$sub.Id].Forecast = $actual + $fAmount
+ }
+ }
+ }
+ catch {
+ # Forecast not available for all account types
+ }
+ }
+ }
+ catch {
+ Write-Warning " Cost query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ return $costMap
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1
new file mode 100644
index 000000000..116301961
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1
@@ -0,0 +1,174 @@
+###########################################################################
+# GET-COSTTREND.PS1
+# AZURE FINOPS MULTITOOL - 6-Month Cost Trend Data
+###########################################################################
+# Purpose: Query Cost Management for the last 6 months of actual spend,
+# returning monthly totals suitable for a bar chart display.
+###########################################################################
+
+function Get-CostTrend {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Querying 6-month cost trend..." -ForegroundColor Cyan
+
+ $endDate = Get-Date -Day 1 # First of current month
+ $startDate = $endDate.AddMonths(-6)
+ $fromStr = $startDate.ToString('yyyy-MM-dd')
+ $toStr = (Get-Date).ToString('yyyy-MM-dd')
+
+ $body = @{
+ type = 'ActualCost'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $fromStr
+ to = $toStr
+ }
+ dataset = @{
+ granularity = 'Monthly'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $months = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $bySubscription = @{} # key = subId, value = sorted list of month entries
+ $useMgScope = Test-MgCostScope
+ $mgPath = "/providers/Microsoft.Management/managementGroups/$TenantId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+
+ # Helper: parse cost query rows into month entries
+ function Parse-CostRows {
+ param($Rows, $Columns)
+ $entries = [System.Collections.Generic.List[PSCustomObject]]::new()
+ if (-not $Rows) { return $entries }
+
+ $costIdx = -1; $dateIdx = -1; $currIdx = -1
+ if ($Columns) {
+ for ($ci = 0; $ci -lt $Columns.Count; $ci++) {
+ $n = $Columns[$ci].name.ToLower()
+ $t = $Columns[$ci].type.ToLower()
+ if ($n -match 'cost|precost|pretaxcost') { $costIdx = $ci }
+ elseif ($t -eq 'number' -and $costIdx -eq -1) { $costIdx = $ci }
+ elseif ($n -match 'billingmonth|usagedate' -or $t -eq 'datetime') { $dateIdx = $ci }
+ elseif ($n -match 'currency|billingcurrency') { $currIdx = $ci }
+ }
+ }
+ if ($costIdx -eq -1) { $costIdx = 0 }
+ if ($dateIdx -eq -1) { $dateIdx = 1 }
+ if ($currIdx -eq -1) { $currIdx = 2 }
+
+ foreach ($row in $Rows) {
+ $cost = [math]::Round([double]$row[$costIdx], 2)
+ $dateVal = $row[$dateIdx].ToString()
+ $dateClean = $dateVal -replace '[^0-9\-]', ''
+ if ($dateClean.Length -eq 8) {
+ $parsed = [datetime]::ParseExact($dateClean, 'yyyyMMdd', $null)
+ } else {
+ $parsed = [datetime]::Parse($dateVal)
+ }
+ $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' }
+ [void]$entries.Add([PSCustomObject]@{
+ Month = $parsed.ToString('MMM yyyy')
+ MonthDate = $parsed
+ Cost = $cost
+ Currency = $currency
+ })
+ }
+ return $entries
+ }
+
+ try {
+ # Try MG scope for aggregate totals
+ if ($useMgScope) {
+ $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $body
+ if ($response.StatusCode -eq 200) {
+ $result = ($response.Content | ConvertFrom-Json)
+ if ($result.properties.rows) {
+ $months = Parse-CostRows -Rows $result.properties.rows -Columns $result.properties.columns
+ }
+ } else {
+ if ($response.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed }
+ Write-Warning " MG-scope cost trend returned HTTP $($response.StatusCode) - falling back to per-sub"
+ $useMgScope = $false
+ }
+ }
+
+ # Always query per-subscription for the subscription dropdown
+ if ($Subscriptions) {
+ $subCount = $Subscriptions.Count
+ $sampleErrors = 0
+ $sampleSize = [math]::Min(3, $subCount)
+ $aggTotals = @{} # used for aggregate if MG scope failed
+
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying cost trend ($i/$subCount subs)..."
+ }
+ }
+
+ $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body
+
+ if ($subResp.StatusCode -eq 200) {
+ $subResult = ($subResp.Content | ConvertFrom-Json)
+ if ($subResult.properties.rows) {
+ $subMonths = Parse-CostRows -Rows $subResult.properties.rows -Columns $subResult.properties.columns
+ $bySubscription[$sub.Id] = @($subMonths | Sort-Object MonthDate)
+
+ # Build aggregate if MG scope didn't work
+ if (-not $useMgScope -or $months.Count -eq 0) {
+ foreach ($sm in $subMonths) {
+ $key = $sm.MonthDate.ToString('yyyy-MM')
+ if (-not $aggTotals.ContainsKey($key)) {
+ $aggTotals[$key] = @{ Cost = 0; Date = $sm.MonthDate; Currency = $sm.Currency }
+ }
+ $aggTotals[$key].Cost += $sm.Cost
+ }
+ }
+ }
+ } else {
+ if ($i -le $sampleSize) { $sampleErrors++ }
+ }
+
+ if ($i -eq $sampleSize -and $sampleErrors -eq $sampleSize -and $subCount -gt $sampleSize) {
+ Write-Host " All $sampleSize sample subs returned errors - skipping remaining $($subCount - $sampleSize) subs" -ForegroundColor Yellow
+ break
+ }
+ }
+
+ # Use aggregated per-sub data if MG scope had no data
+ if ($months.Count -eq 0 -and $aggTotals.Count -gt 0) {
+ foreach ($entry in $aggTotals.GetEnumerator() | Sort-Object Key) {
+ [void]$months.Add([PSCustomObject]@{
+ Month = $entry.Value.Date.ToString('MMM yyyy')
+ MonthDate = $entry.Value.Date
+ Cost = [math]::Round($entry.Value.Cost, 2)
+ Currency = $entry.Value.Currency
+ })
+ }
+ }
+ }
+ } catch {
+ Write-Warning "Cost trend query failed: $($_.Exception.Message)"
+ }
+
+ # Sort by date
+ $sorted = @($months | Sort-Object MonthDate)
+
+ return [PSCustomObject]@{
+ Months = $sorted
+ BySubscription = $bySubscription
+ HasData = ($sorted.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1
new file mode 100644
index 000000000..3b73385b5
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1
@@ -0,0 +1,143 @@
+###########################################################################
+# GET-IDLEVMS.PS1
+# AZURE FINOPS MULTITOOL - Idle & Underutilized VM Detection
+###########################################################################
+# Purpose: Query Azure Monitor metrics to find running VMs with very low
+# CPU and network utilization that Advisor hasn't flagged yet.
+# These are candidates for downsizing or shutting down.
+###########################################################################
+
+function Get-IdleVMs {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Scanning for idle and underutilized VMs..." -ForegroundColor Cyan
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $results = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # -- 1: Find all running VMs ------------------------------------------
+ try {
+ $query = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| extend powerState = tostring(properties.extended.instanceView.powerState.code)
+| where powerState =~ 'PowerState/running'
+| project name, resourceGroup, subscriptionId, location,
+ vmSize = properties.hardwareProfile.vmSize,
+ osType = properties.storageProfile.osDisk.osType,
+ powerState
+"@
+ $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000
+ $runningVMs = if ($result) { @($result.Data) } else { @() }
+ Write-Host " Running VMs found: $($runningVMs.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Running VM query failed: $($_.Exception.Message)"
+ $runningVMs = @()
+ }
+
+ if ($runningVMs.Count -eq 0) {
+ return [PSCustomObject]@{
+ IdleVMs = @()
+ Count = 0
+ HasData = $false
+ ScannedVMs = 0
+ }
+ }
+
+ # -- 2: Query 14-day avg CPU + Network for each VM -------------------
+ $token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').Token
+ $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' }
+ $now = (Get-Date).ToUniversalTime()
+ $fourteenDaysAgo = $now.AddDays(-14).ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $nowStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ')
+
+ $cpuThreshold = 5 # avg CPU < 5% = idle
+ $networkThreshold = 1048576 # < 1 MB/day total network = idle (14d * 1MB = 14MB)
+ $networkThreshold14d = $networkThreshold * 14
+
+ $vmCount = $runningVMs.Count
+ $vmIdx = 0
+ foreach ($vm in $runningVMs) {
+ $vmIdx++
+ if ($vmCount -gt 10 -and ($vmIdx -eq 1 -or $vmIdx % [math]::Max(1, [int]($vmCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Checking VM metrics ($vmIdx/$vmCount VMs)..."
+ }
+ }
+ $scope = "/subscriptions/$($vm.subscriptionId)/resourceGroups/$($vm.resourceGroup)/providers/Microsoft.Compute/virtualMachines/$($vm.name)"
+ try {
+ # Query CPU + Network In + Network Out in a single call
+ $metricUri = "https://management.azure.com$scope/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Percentage CPU,Network In Total,Network Out Total×pan=$fourteenDaysAgo/$nowStr&aggregation=Average,Total&interval=P14D"
+ $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop
+ $metricData = ($resp.Content | ConvertFrom-Json)
+
+ $avgCpu = $null
+ $totalNetIn = 0
+ $totalNetOut = 0
+
+ foreach ($metric in $metricData.value) {
+ $metricName = $metric.name.value
+ foreach ($ts in $metric.timeseries) {
+ foreach ($dp in $ts.data) {
+ switch ($metricName) {
+ 'Percentage CPU' {
+ if ($dp.average -ne $null) { $avgCpu = $dp.average }
+ }
+ 'Network In Total' {
+ if ($dp.total) { $totalNetIn += $dp.total }
+ }
+ 'Network Out Total' {
+ if ($dp.total) { $totalNetOut += $dp.total }
+ }
+ }
+ }
+ }
+ }
+
+ $totalNetwork = $totalNetIn + $totalNetOut
+
+ # Classify: idle if CPU < threshold AND network < threshold
+ $isIdle = $false
+ $classification = $null
+
+ if ($avgCpu -ne $null -and $avgCpu -lt $cpuThreshold -and $totalNetwork -lt $networkThreshold14d) {
+ $isIdle = $true
+ $classification = 'Idle'
+ } elseif ($avgCpu -ne $null -and $avgCpu -lt 10 -and $totalNetwork -lt ($networkThreshold14d * 10)) {
+ $isIdle = $true
+ $classification = 'Underutilized'
+ }
+
+ if ($isIdle) {
+ $dailyNetMB = [math]::Round($totalNetwork / 14 / 1MB, 2)
+ [void]$results.Add([PSCustomObject]@{
+ VMName = $vm.name
+ ResourceGroup = $vm.resourceGroup
+ SubscriptionId = $vm.subscriptionId
+ Location = $vm.location
+ VMSize = $vm.vmSize
+ OS = $vm.osType
+ AvgCPU14d = [math]::Round($avgCpu, 1)
+ NetworkPerDay = "$($dailyNetMB) MB"
+ Classification = $classification
+ Recommendation = if ($classification -eq 'Idle') { 'Deallocate or delete' } else { 'Downsize VM' }
+ })
+ }
+ } catch {
+ # Metrics not available — skip this VM
+ }
+ }
+
+ Write-Host " Idle/underutilized VMs: $($results.Count)" -ForegroundColor Gray
+
+ [PSCustomObject]@{
+ IdleVMs = @($results)
+ Count = $results.Count
+ HasData = ($results.Count -gt 0)
+ ScannedVMs = $runningVMs.Count
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1
new file mode 100644
index 000000000..21865787b
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1
@@ -0,0 +1,173 @@
+###########################################################################
+# GET-OPTIMIZATIONADVICE.PS1
+# AZURE FINOPS MULTITOOL - Azure Advisor Cost Optimization
+###########################################################################
+# Purpose: Pull all cost optimization recommendations from Azure Advisor
+# across every subscription. Categorize by type: rightsize,
+# shutdown, delete, modernize.
+#
+# Reference: https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations
+###########################################################################
+
+function Get-OptimizationAdvice {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $allRecs = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # Build subscription ID list and name lookup
+ $subIds = @($Subscriptions | ForEach-Object { $_.Id })
+ $subNameMap = @{}
+ foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id] = $sub.Name }
+
+ # Query all Advisor cost recommendations via Resource Graph (single call)
+ $query = @"
+advisorresources
+| where type == 'microsoft.advisor/recommendations'
+| where properties.category == 'Cost'
+| project subscriptionId,
+ shortDescriptionProblem = tostring(properties.shortDescription.problem),
+ shortDescriptionSolution = tostring(properties.shortDescription.solution),
+ impact = tostring(properties.impact),
+ impactedField = tostring(properties.impactedField),
+ impactedValue = tostring(properties.impactedValue),
+ annualSavings = tostring(properties.extendedProperties.annualSavingsAmount),
+ savingsAmount = tostring(properties.extendedProperties.savingsAmount),
+ savingsCurrency = tostring(properties.extendedProperties.savingsCurrency)
+"@
+
+ try {
+ Write-Host " Querying Advisor cost recommendations via Resource Graph..." -ForegroundColor Cyan
+ $allRows = [System.Collections.Generic.List[object]]::new()
+ $skipToken = $null
+
+ do {
+ $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -SkipToken $skipToken
+ if ($result -and $result.Data) { foreach ($r in $result.Data) { [void]$allRows.Add($r) } }
+ $skipToken = if ($result) { $result.SkipToken } else { $null }
+ } while ($skipToken)
+
+ Write-Host " Retrieved $($allRows.Count) Advisor cost recommendations." -ForegroundColor Cyan
+
+ foreach ($row in $allRows) {
+ $problem = $row.shortDescriptionProblem
+ $solution = $row.shortDescriptionSolution
+
+ # Skip reservation/savings plan recs (handled by Get-ReservationAdvice)
+ if ($problem -match 'reserv|savings plan') { continue }
+
+ # Categorize the recommendation
+ $catText = "$problem $solution"
+ $category = switch -Regex ($catText) {
+ 'right.?siz|resize|downsize|scale down' { 'Rightsize' }
+ 'shut.?down|deallocate|idle|stopped' { 'Shutdown / Deallocate' }
+ 'delet|unused|orphan|unattached' { 'Delete Unused' }
+ 'modern|upgrade|migrate|move to' { 'Modernize' }
+ 'burstable|B-series' { 'Rightsize' }
+ default { 'Other' }
+ }
+
+ $savings = $null
+ if ($row.annualSavings) {
+ $savings = [math]::Round([double]$row.annualSavings, 2)
+ }
+ elseif ($row.savingsAmount) {
+ $savings = [math]::Round([double]$row.savingsAmount, 2)
+ }
+
+ $subId = $row.subscriptionId
+ [void]$allRecs.Add([PSCustomObject]@{
+ Subscription = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId }
+ SubscriptionId = $subId
+ Category = $category
+ Impact = $row.impact
+ Problem = $problem
+ Solution = $solution
+ ResourceType = $row.impactedField
+ ResourceName = $row.impactedValue
+ AnnualSavings = $savings
+ Currency = $row.savingsCurrency
+ })
+ }
+ } catch {
+ Write-Warning " Advisor Resource Graph query failed: $($_.Exception.Message)"
+ Write-Warning " Falling back to per-subscription REST calls..."
+
+ # Fallback: per-subscription REST API (slow but reliable)
+ foreach ($sub in $Subscriptions) {
+ try {
+ $advPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&`$filter=Category eq 'Cost'"
+ $advResp = Invoke-AzRestMethodWithRetry -Path $advPath -Method GET
+ if ($advResp.StatusCode -ne 200) { continue }
+ $advResult = ($advResp.Content | ConvertFrom-Json)
+
+ foreach ($item in $advResult.value) {
+ $rec = $item.properties
+ if ($rec.shortDescription.problem -match 'reserv|savings plan') { continue }
+
+ $catText = "$($rec.shortDescription.problem) $($rec.shortDescription.solution)"
+ $category = switch -Regex ($catText) {
+ 'right.?siz|resize|downsize|scale down' { 'Rightsize' }
+ 'shut.?down|deallocate|idle|stopped' { 'Shutdown / Deallocate' }
+ 'delet|unused|orphan|unattached' { 'Delete Unused' }
+ 'modern|upgrade|migrate|move to' { 'Modernize' }
+ 'burstable|B-series' { 'Rightsize' }
+ default { 'Other' }
+ }
+
+ $savings = $null
+ if ($rec.extendedProperties.annualSavingsAmount) {
+ $savings = [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2)
+ } elseif ($rec.extendedProperties.savingsAmount) {
+ $savings = [math]::Round([double]$rec.extendedProperties.savingsAmount, 2)
+ }
+
+ [void]$allRecs.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ Category = $category
+ Impact = $rec.impact
+ Problem = $rec.shortDescription.problem
+ Solution = $rec.shortDescription.solution
+ ResourceType = $rec.impactedField
+ ResourceName = $rec.impactedValue
+ AnnualSavings = $savings
+ Currency = $rec.extendedProperties.savingsCurrency
+ })
+ }
+ } catch {
+ Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Summarize by category ------------------------------------------
+ $byCat = $allRecs | Group-Object Category | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Name
+ Count = $_.Count
+ TotalSavings = [math]::Round(($_.Group | Where-Object { $_.AnnualSavings } |
+ Measure-Object -Property AnnualSavings -Sum).Sum, 2)
+ }
+ }
+
+ $totalSavings = ($allRecs | Where-Object { $_.AnnualSavings } |
+ Measure-Object -Property AnnualSavings -Sum).Sum
+
+ # -- Summarize by impact --------------------------------------------
+ $byImpact = $allRecs | Group-Object Impact | ForEach-Object {
+ [PSCustomObject]@{ Impact = $_.Name; Count = $_.Count }
+ }
+
+ return [PSCustomObject]@{
+ Recommendations = $allRecs
+ ByCategory = $byCat
+ ByImpact = $byImpact
+ TotalCount = $allRecs.Count
+ EstimatedAnnualSavings = [math]::Round($totalSavings, 2)
+ Summary = "$($allRecs.Count) optimization recommendations (est. `$$([math]::Round($totalSavings, 2))/yr savings)"
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1
new file mode 100644
index 000000000..761034ee3
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1
@@ -0,0 +1,216 @@
+###########################################################################
+# GET-ORPHANEDRESOURCES.PS1
+# AZURE FINOPS MULTITOOL - Orphaned & Idle Resource Detection
+###########################################################################
+# Purpose: Use Azure Resource Graph to find resources that are costing
+# money but serving no purpose: orphaned disks, unattached IPs,
+# empty App Service Plans, unattached NICs, and stopped VMs
+# that are still incurring compute charges.
+###########################################################################
+
+function Get-OrphanedResources {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Scanning for orphaned and idle resources..." -ForegroundColor Cyan
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $allOrphans = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # -- 1: Orphaned Managed Disks (no ownerVM) --------------------------
+ try {
+ $diskQuery = @"
+resources
+| where type =~ 'microsoft.compute/disks'
+| where managedBy == '' or isnull(managedBy)
+| where properties.diskState == 'Unattached'
+| project name, resourceGroup, subscriptionId, location,
+ diskSizeGb = properties.diskSizeGB,
+ sku = sku.name, diskState = properties.diskState,
+ type = 'Orphaned Disk'
+"@
+ $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Orphaned Disk'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.diskSizeGb) GB ($($r.sku))"
+ Impact = 'Medium'
+ })
+ }
+ Write-Host " Orphaned disks: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Orphaned disk query failed: $($_.Exception.Message)"
+ }
+
+ # -- 2: Unattached Public IPs -----------------------------------------
+ try {
+ $pipQuery = @"
+resources
+| where type =~ 'microsoft.network/publicipaddresses'
+| where properties.ipConfiguration == '' or isnull(properties.ipConfiguration)
+| where properties.natGateway == '' or isnull(properties.natGateway)
+| project name, resourceGroup, subscriptionId, location,
+ sku = sku.name, ipAddress = properties.ipAddress,
+ allocationMethod = properties.publicIPAllocationMethod,
+ type = 'Unattached Public IP'
+"@
+ $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Unattached Public IP'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.sku) - $($r.allocationMethod)"
+ Impact = if ($r.sku -eq 'Standard') { 'Medium' } else { 'Low' }
+ })
+ }
+ Write-Host " Unattached public IPs: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Unattached public IP query failed: $($_.Exception.Message)"
+ }
+
+ # -- 3: Unattached NICs -----------------------------------------------
+ try {
+ $nicQuery = @"
+resources
+| where type =~ 'microsoft.network/networkinterfaces'
+| where isnull(properties.virtualMachine) or properties.virtualMachine == ''
+| where isnull(properties.privateEndpoint) or properties.privateEndpoint == ''
+| project name, resourceGroup, subscriptionId, location,
+ enableAcceleratedNetworking = properties.enableAcceleratedNetworking,
+ type = 'Unattached NIC'
+"@
+ $result = Search-AzGraphSafe -Query $nicQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Unattached NIC'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "Accelerated: $($r.enableAcceleratedNetworking)"
+ Impact = 'Low'
+ })
+ }
+ Write-Host " Unattached NICs: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Unattached NIC query failed: $($_.Exception.Message)"
+ }
+
+ # -- 4: Stopped (deallocated) VMs still on disk -----------------------
+ try {
+ $vmQuery = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| where properties.extended.instanceView.powerState.displayStatus == 'VM deallocated'
+ or properties.extended.instanceView.powerState.code == 'PowerState/deallocated'
+| project name, resourceGroup, subscriptionId, location,
+ vmSize = properties.hardwareProfile.vmSize,
+ powerState = properties.extended.instanceView.powerState.displayStatus,
+ type = 'Deallocated VM'
+"@
+ $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Deallocated VM'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.vmSize) - still incurs disk/IP costs"
+ Impact = 'Medium'
+ })
+ }
+ Write-Host " Deallocated VMs: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Deallocated VM query failed: $($_.Exception.Message)"
+ }
+
+ # -- 5: Empty App Service Plans (0 apps) ------------------------------
+ try {
+ $aspQuery = @"
+resources
+| where type =~ 'microsoft.web/serverfarms'
+| where properties.numberOfSites == 0
+| where sku.tier != 'Free' and sku.tier != 'Shared'
+| project name, resourceGroup, subscriptionId, location,
+ sku = strcat(sku.tier, ' / ', sku.name),
+ workers = properties.numberOfWorkers,
+ type = 'Empty App Service Plan'
+"@
+ $result = Search-AzGraphSafe -Query $aspQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Empty App Service Plan'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.sku), $($r.workers) worker(s), 0 apps"
+ Impact = 'High'
+ })
+ }
+ Write-Host " Empty App Service Plans: $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Empty ASP query failed: $($_.Exception.Message)"
+ }
+
+ # -- 6: Orphaned Snapshots (older than 30 days) -----------------------
+ try {
+ $snapshotCutoff = (Get-Date).AddDays(-30).ToString('yyyy-MM-dd')
+ $snapQuery = @"
+resources
+| where type =~ 'microsoft.compute/snapshots'
+| where properties.timeCreated < datetime('$snapshotCutoff')
+| project name, resourceGroup, subscriptionId, location,
+ diskSizeGb = properties.diskSizeGB,
+ timeCreated = properties.timeCreated,
+ type = 'Old Snapshot'
+"@
+ $result = Search-AzGraphSafe -Query $snapQuery -Subscription $subIds -First 1000
+ $rows = if ($result) { @($result.Data) } else { @() }
+ foreach ($r in $rows) {
+ [void]$allOrphans.Add([PSCustomObject]@{
+ Category = 'Old Snapshot (30d+)'
+ ResourceName = $r.name
+ ResourceGroup = $r.resourceGroup
+ SubscriptionId = $r.subscriptionId
+ Location = $r.location
+ Detail = "$($r.diskSizeGb) GB, created $($r.timeCreated)"
+ Impact = 'Low'
+ })
+ }
+ Write-Host " Old snapshots (30d+): $($rows.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Snapshot query failed: $($_.Exception.Message)"
+ }
+
+ # -- Summary by category --
+ $summary = $allOrphans | Group-Object Category | ForEach-Object {
+ [PSCustomObject]@{
+ Category = $_.Name
+ Count = $_.Count
+ }
+ }
+
+ return [PSCustomObject]@{
+ Orphans = @($allOrphans)
+ Summary = @($summary)
+ TotalCount = $allOrphans.Count
+ HasData = ($allOrphans.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1
new file mode 100644
index 000000000..2477e3b18
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1
@@ -0,0 +1,283 @@
+###########################################################################
+# GET-POLICYINVENTORY.PS1
+# AZURE FINOPS MULTITOOL - Policy Inventory Across the Tenant
+###########################################################################
+# Purpose: Scan all policy assignments across the tenant's subscriptions
+# and return a summary of assigned policies, their effects,
+# scopes, and compliance state.
+#
+# Strategy: Resource Graph for assignments (1 paginated call) +
+# MG-scope Policy Insights for compliance (1 call).
+# Falls back to per-sub only for small tenants if above fail.
+###########################################################################
+
+function Get-PolicyInventory {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [string]$TenantId,
+
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $subCount = $Subscriptions.Count
+ Write-Host " Scanning policy assignments across $subCount subscriptions..." -ForegroundColor Cyan
+
+ $allAssignments = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $complianceMap = @{}
+ $gotAssignments = $false
+ $gotCompliance = $false
+
+ # -- Strategy 1: ARM REST API for ALL effective assignments ----------
+ # Resource Graph policyresources at subscription scope only returns
+ # assignments AT that scope. The ARM Policy API returns ALL effective
+ # assignments including those inherited from management groups and
+ # the tenant root group.
+ try {
+ Write-Host " Querying policy assignments via ARM REST API..." -ForegroundColor Cyan
+ $seenIds = @{}
+ foreach ($sub in $Subscriptions) {
+ $subName = $sub.Name
+ $nextLink = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01"
+ while ($nextLink) {
+ $resp = Invoke-AzRestMethodWithRetry -Path $nextLink -Method GET
+ if ($resp.StatusCode -ne 200) { break }
+ $body = $resp.Content | ConvertFrom-Json
+ foreach ($a in $body.value) {
+ # De-duplicate (same MG assignment appears under each sub)
+ if ($seenIds.ContainsKey($a.id)) { continue }
+ $seenIds[$a.id] = $true
+
+ $props = $a.properties
+ $defId = $props.policyDefinitionId
+ $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' }
+ elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' }
+ else { 'Custom' }
+ $scope = if ($a.id -match '^(.*)/providers/Microsoft\.Authorization/policyAssignments/') {
+ $Matches[1]
+ } else { '' }
+
+ [void]$allAssignments.Add([PSCustomObject]@{
+ AssignmentName = if ($props.displayName) { $props.displayName } else { $a.name }
+ AssignmentId = $a.id
+ PolicyDefId = $defId
+ Scope = $scope
+ Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' }
+ EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' }
+ Origin = $origin
+ Subscription = $subName
+ Description = if ($props.description) { $props.description } else { '' }
+ })
+ }
+ # Handle pagination via nextLink
+ $nextLink = if ($body.nextLink) {
+ $body.nextLink -replace '^https://management\.azure\.com', ''
+ } else { $null }
+ }
+ }
+
+ if ($allAssignments.Count -gt 0) {
+ $gotAssignments = $true
+ Write-Host " ARM REST API: $($allAssignments.Count) unique policy assignments (including inherited)" -ForegroundColor Green
+ }
+ } catch {
+ Write-Warning " ARM REST policy query failed: $($_.Exception.Message)"
+ }
+
+ # Fallback: Resource Graph if ARM REST didn't find any
+ if (-not $gotAssignments) {
+ try {
+ Write-Host " Falling back to Resource Graph for policy assignments..." -ForegroundColor Yellow
+ $argQuery = @"
+policyresources
+| where type =~ 'microsoft.authorization/policyassignments'
+| project id, name, properties, subscriptionId, type
+"@
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $skipToken = $null
+ $pageNum = 0
+ do {
+ $pageNum++
+ $result = Search-AzGraphSafe -Query $argQuery -Subscription $subIds -First 1000 -SkipToken $skipToken
+ if ($result -and $result.Data) {
+ foreach ($r in $result.Data) {
+ $props = $r.properties
+ $defId = $props.policyDefinitionId
+ $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' }
+ elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' }
+ else { 'Custom' }
+ $subName = $r.subscriptionId
+ $matchSub = $Subscriptions | Where-Object { $_.Id -eq $r.subscriptionId } | Select-Object -First 1
+ if ($matchSub) { $subName = $matchSub.Name }
+ [void]$allAssignments.Add([PSCustomObject]@{
+ AssignmentName = if ($props.displayName) { $props.displayName } else { $r.name }
+ AssignmentId = $r.id
+ PolicyDefId = $defId
+ Scope = if ($props.scope) { $props.scope } else { ($r.id -replace '/providers/Microsoft\.Authorization/policyAssignments/.*', '') }
+ Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' }
+ EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' }
+ Origin = $origin
+ Subscription = $subName
+ Description = if ($props.description) { $props.description } else { '' }
+ })
+ }
+ $skipToken = $result.SkipToken
+ } else { $skipToken = $null }
+ } while ($skipToken)
+ if ($allAssignments.Count -gt 0) {
+ $gotAssignments = $true
+ Write-Host " Resource Graph fallback: $($allAssignments.Count) assignments" -ForegroundColor Green
+ }
+ } catch {
+ Write-Warning " Resource Graph policy query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Strategy 2: Resource Graph for compliance (tenant-wide, fast) ---
+ # The MG-scope PolicyInsights summarize REST API hangs indefinitely,
+ # and per-sub REST loops are slow on large tenants.
+ # Resource Graph policyresources table gives us compliance across ALL
+ # subscriptions in a single paginated call - fast and complete.
+ try {
+ Write-Host " Querying policy compliance via Resource Graph..." -ForegroundColor Cyan
+ $compQuery = @"
+policyresources
+| where type =~ 'microsoft.policyinsights/policystates'
+| extend complianceState = tostring(properties.complianceState)
+| summarize
+ Compliant = countif(complianceState =~ 'Compliant'),
+ NonCompliant = countif(complianceState =~ 'NonCompliant'),
+ Total = count()
+ by subscriptionId
+"@
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $compResult = Search-AzGraphSafe -Query $compQuery -Subscription $subIds -First 1000
+
+ if ($compResult -and $compResult.Data -and $compResult.Data.Count -gt 0) {
+ foreach ($row in $compResult.Data) {
+ $subName = $row.subscriptionId
+ $matchSub = $Subscriptions | Where-Object { $_.Id -eq $row.subscriptionId } | Select-Object -First 1
+ if ($matchSub) { $subName = $matchSub.Name }
+
+ $complianceMap[$row.subscriptionId] = [PSCustomObject]@{
+ Subscription = $subName
+ SubscriptionId = $row.subscriptionId
+ TotalResources = $row.Total
+ NonCompliant = $row.NonCompliant
+ Compliant = $row.Compliant
+ PolicyCount = 0
+ }
+ }
+ $gotCompliance = $true
+ Write-Host " Resource Graph compliance: $($complianceMap.Count) subscriptions" -ForegroundColor Green
+ }
+ } catch {
+ Write-Warning " Resource Graph compliance query failed: $($_.Exception.Message)"
+ }
+
+ # -- Compliance fallback: per-sub REST (only if ARG compliance failed) --
+ if (-not $gotCompliance) {
+ Write-Host " Falling back to per-sub compliance queries..." -ForegroundColor Yellow
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($subCount -gt 20 -and ($i % 10 -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Scanning policy compliance ($i/$subCount)..."
+ }
+ }
+ try {
+ $compPath = "/subscriptions/$($sub.Id)/providers/Microsoft.PolicyInsights/policyStates/latest/summarize?api-version=2019-10-01"
+ $compResp = Invoke-AzRestMethodWithRetry -Path $compPath -Method POST
+ if ($compResp.StatusCode -eq 200) {
+ $summary = ($compResp.Content | ConvertFrom-Json).value
+ if ($summary -and $summary.Count -gt 0) {
+ $s = $summary[0].results
+ $complianceMap[$sub.Id] = [PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ TotalResources = $s.resourceDetails | ForEach-Object { $_.count } | Measure-Object -Sum | Select-Object -ExpandProperty Sum
+ NonCompliant = ($s.resourceDetails | Where-Object { $_.complianceState -eq 'noncompliant' }).count
+ Compliant = ($s.resourceDetails | Where-Object { $_.complianceState -eq 'compliant' }).count
+ PolicyCount = $s.policyDetails | ForEach-Object { $_.count } | Measure-Object -Sum | Select-Object -ExpandProperty Sum
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Policy compliance failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Strategy 3: Per-sub fallback (only if Resource Graph failed) ---
+ if (-not $gotAssignments) {
+ Write-Host " Falling back to per-subscription policy scan..." -ForegroundColor Yellow
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Scanning policies ($i/$subCount subs)..."
+ }
+ }
+ try {
+ $assignPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $assignPath -Method GET
+ if ($resp.StatusCode -eq 200) {
+ $assignments = ($resp.Content | ConvertFrom-Json).value
+ foreach ($a in $assignments) {
+ $props = $a.properties
+ $defId = $props.policyDefinitionId
+ $origin = if ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } else { 'Custom' }
+ if ($defId -match '/policySetDefinitions/') { $origin = 'Initiative' }
+
+ [void]$allAssignments.Add([PSCustomObject]@{
+ AssignmentName = $props.displayName
+ AssignmentId = $a.id
+ PolicyDefId = $defId
+ Scope = $props.scope
+ Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' }
+ EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' }
+ Origin = $origin
+ Subscription = $sub.Name
+ Description = if ($props.description) { $props.description } else { '' }
+ })
+ }
+ }
+ } catch {
+ Write-Warning " Policy assignments failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Deduplicate assignments by name + scope -----------------------
+ $seen = @{}
+ $unique = [System.Collections.Generic.List[PSCustomObject]]::new()
+ foreach ($a in $allAssignments) {
+ $key = "$($a.AssignmentName)|$($a.Scope)"
+ if (-not $seen.ContainsKey($key)) {
+ $seen[$key] = $true
+ [void]$unique.Add($a)
+ }
+ }
+
+ # -- Compliance totals ---------------------------------------------
+ $totalCompliant = 0
+ $totalNonCompliant = 0
+ foreach ($c in $complianceMap.Values) {
+ $totalCompliant += $c.Compliant
+ $totalNonCompliant += $c.NonCompliant
+ }
+ $totalEvaluated = $totalCompliant + $totalNonCompliant
+ $compliancePct = if ($totalEvaluated -gt 0) { [math]::Round(($totalCompliant / $totalEvaluated) * 100, 1) } else { 0 }
+
+ return [PSCustomObject]@{
+ Assignments = $unique
+ AssignmentCount = $unique.Count
+ ComplianceBySubMap = $complianceMap
+ CompliancePct = $compliancePct
+ TotalCompliant = $totalCompliant
+ TotalNonCompliant = $totalNonCompliant
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1
new file mode 100644
index 000000000..d615843d9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1
@@ -0,0 +1,251 @@
+###########################################################################
+# GET-POLICYRECOMMENDATIONS.PS1
+# AZURE FINOPS MULTITOOL - FinOps Policy Recommendations
+###########################################################################
+# Purpose: Compare the customer's existing policy assignments against a
+# curated list of Microsoft-recommended FinOps/cost governance
+# policies (Azure built-in policy definitions).
+#
+# Sources:
+# - Azure built-in policies (Tags, General, Compute, Storage categories)
+# - Microsoft Cloud Adoption Framework cost governance guidance
+# - AzAdvertizer.net policy catalog reference
+#
+# Each recommendation includes the built-in policy definition ID so
+# it can be deployed directly from the GUI.
+###########################################################################
+
+function Get-PolicyRecommendations {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$ExistingAssignments # Policy assignment objects from Get-PolicyInventory
+ )
+
+ # -- Curated FinOps / Cost Governance Policies ----------------------
+ # These are Azure built-in policy definition IDs verified from
+ # https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies
+ $recommendedPolicies = @(
+ # === TAGGING & NAMING (CAF: Enforce Tagging and Naming) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/726aca4c-86e9-4b04-b0c5-073027359532'
+ DisplayName = 'Require a tag on resources'
+ Category = 'Tags'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Enforce tagging on all resources for cost allocation and chargeback visibility'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags'
+ Parameters = @(
+ @{ Name = 'tagName'; Label = 'Tag name (e.g. CostCenter)'; Required = $true }
+ @{ Name = 'tagValue'; Label = 'Tag value (leave blank for any value)'; Required = $false }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/96670d01-0a4d-4649-9c89-2d3abc0a5025'
+ DisplayName = 'Require a tag on resource groups'
+ Category = 'Tags'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Enforce tagging on resource groups for cost allocation at the container level'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags'
+ Parameters = @(
+ @{ Name = 'tagName'; Label = 'Tag name (e.g. CostCenter)'; Required = $true }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcbef5-37f7'
+ DisplayName = 'Inherit a tag from the resource group if missing'
+ Category = 'Tags'
+ Pillar = 'Understand'
+ Priority = 'Recommended'
+ DefaultEffect = 'Modify'
+ AllowedEffects = @('Modify','Disabled')
+ Purpose = 'Auto-inherit tags from resource group to child resources for consistent cost allocation'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags'
+ Parameters = @(
+ @{ Name = 'tagName'; Label = 'Tag name to inherit (e.g. CostCenter)'; Required = $true }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/40df99da-1232-49b1-a39a-6da8d878f469'
+ DisplayName = 'Inherit a tag from the subscription if missing'
+ Category = 'Tags'
+ Pillar = 'Understand'
+ Priority = 'Recommended'
+ DefaultEffect = 'Modify'
+ AllowedEffects = @('Modify','Disabled')
+ Purpose = 'Auto-inherit tags from subscription to resources for top-level cost allocation'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags'
+ Parameters = @(
+ @{ Name = 'tagName'; Label = 'Tag name to inherit (e.g. CostCenter)'; Required = $true }
+ )
+ }
+
+ # === ALLOWED RESOURCE LOCATIONS (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c'
+ DisplayName = 'Allowed locations'
+ Category = 'General'
+ Pillar = 'Optimize'
+ Priority = 'Required'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Restrict resource deployment to authorized Azure regions for compliance and cost control'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#general'
+ Parameters = @(
+ @{ Name = 'listOfAllowedLocations'; Label = 'Allowed locations (comma-separated, e.g. eastus,westus2,centralus)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === RESTRICT VM SIZES (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/cccc23c7-8427-4f53-ad12-b6a63eb452b3'
+ DisplayName = 'Allowed virtual machine size SKUs'
+ Category = 'Compute'
+ Pillar = 'Optimize'
+ Priority = 'Required'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Restrict VM sizes to prevent over-provisioning and control compute costs'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#compute'
+ Parameters = @(
+ @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed VM SKUs (comma-separated, e.g. Standard_D2s_v3,Standard_B2ms)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === ALLOWED STORAGE ACCOUNT SKUS (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1'
+ DisplayName = 'Allowed storage account SKUs'
+ Category = 'Storage'
+ Pillar = 'Optimize'
+ Priority = 'Recommended'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Restrict storage account types to control costs and enforce standard tiers'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#storage'
+ Parameters = @(
+ @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed storage SKUs (comma-separated, e.g. Standard_LRS,Standard_GRS,Standard_ZRS)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === ALLOWED DISK SKUS (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/06a78e20-9358-41c9-923c-fb736d382a4d'
+ DisplayName = 'Allowed managed disk SKUs'
+ Category = 'Compute'
+ Pillar = 'Optimize'
+ Priority = 'Recommended'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Restrict managed disk types to prevent costly Premium or Ultra disks where not needed'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#compute'
+ Parameters = @(
+ @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed disk SKUs (comma-separated, e.g. Standard_LRS,StandardSSD_LRS,Premium_LRS)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === DEPLOY DIAGNOSTIC SETTINGS (CAF) ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7f89b1eb-583c-429a-8828-af049802c1d9'
+ DisplayName = 'Audit diagnostic setting'
+ Category = 'Monitoring'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ DefaultEffect = 'AuditIfNotExists'
+ AllowedEffects = @('AuditIfNotExists','Disabled')
+ Purpose = 'Automatically enable logging for diagnostics - ensures visibility into resource operations and costs'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#monitoring'
+ Parameters = @(
+ @{ Name = 'listOfResourceTypes'; Label = 'Resource types to audit (comma-separated, e.g. Microsoft.Compute/virtualMachines,Microsoft.Sql/servers,Microsoft.Storage/storageAccounts)'; Required = $true; IsArray = $true }
+ )
+ }
+
+ # === ADDITIONAL FINOPS-ALIGNED ===
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/6c112d4e-5bc7-47ae-a041-ea2d9dccd749'
+ DisplayName = 'Not allowed resource types'
+ Category = 'General'
+ Pillar = 'Optimize'
+ Priority = 'Recommended'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Block expensive or unnecessary resource types to reduce cost sprawl'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#general'
+ Parameters = @(
+ @{ Name = 'listOfResourceTypesNotAllowed'; Label = 'Resource types to block (comma-separated, e.g. Microsoft.Sql/servers,Microsoft.HDInsight/clusters)'; Required = $true; IsArray = $true }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1'
+ DisplayName = 'Storage accounts should be limited by allowed SKUs'
+ Category = 'Storage'
+ Pillar = 'Optimize'
+ Priority = 'Recommended'
+ DefaultEffect = 'Deny'
+ AllowedEffects = @('Audit','Deny','Disabled')
+ Purpose = 'Prevent Premium storage where Standard suffices to reduce storage costs'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#storage'
+ Parameters = @(
+ @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed storage SKUs (comma-separated, e.g. Standard_LRS,Standard_GRS)'; Required = $true; IsArray = $true }
+ )
+ }
+ [PSCustomObject]@{
+ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/013e242c-8828-4970-87b3-ab247555486d'
+ DisplayName = 'Azure Backup should be enabled for Virtual Machines'
+ Category = 'Backup'
+ Pillar = 'Quantify'
+ Priority = 'Recommended'
+ DefaultEffect = 'AuditIfNotExists'
+ AllowedEffects = @('AuditIfNotExists','Disabled')
+ Purpose = 'Ensure VMs are backed up to prevent costly data loss recovery scenarios'
+ Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#backup'
+ }
+ )
+
+ # -- Match existing assignments against recommendations ------------
+ $existingDefIds = @{}
+ $existingNames = @{}
+ foreach ($a in $ExistingAssignments) {
+ if ($a.PolicyDefId) {
+ $existingDefIds[$a.PolicyDefId.ToLower()] = $true
+ }
+ if ($a.AssignmentName) {
+ $existingNames[$a.AssignmentName.ToLower()] = $true
+ }
+ }
+
+ $analysis = foreach ($rec in $recommendedPolicies) {
+ $foundById = $existingDefIds.ContainsKey($rec.PolicyDefId.ToLower())
+ $foundByName = $existingNames.ContainsKey($rec.DisplayName.ToLower())
+ $status = if ($foundById -or $foundByName) { 'Assigned' } else { 'Missing' }
+
+ [PSCustomObject]@{
+ DisplayName = $rec.DisplayName
+ Status = $status
+ Category = $rec.Category
+ Pillar = $rec.Pillar
+ Priority = $rec.Priority
+ DefaultEffect = $rec.DefaultEffect
+ AllowedEffects = $rec.AllowedEffects
+ Purpose = $rec.Purpose
+ PolicyDefId = $rec.PolicyDefId
+ Reference = $rec.Reference
+ Parameters = if ($rec.Parameters) { $rec.Parameters } else { @() }
+ }
+ }
+
+ $missing = @($analysis | Where-Object { $_.Status -eq 'Missing' })
+ $assigned = @($analysis | Where-Object { $_.Status -eq 'Assigned' })
+
+ return [PSCustomObject]@{
+ Analysis = $analysis
+ Missing = $missing
+ Assigned = $assigned
+ CompliancePct = [math]::Round(($assigned.Count / $analysis.Count) * 100, 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1
new file mode 100644
index 000000000..51d16779d
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1
@@ -0,0 +1,161 @@
+###########################################################################
+# GET-RESERVATIONADVICE.PS1
+# AZURE FINOPS MULTITOOL - Reservation & Savings Plan Recommendations
+###########################################################################
+# Purpose: Pull RI (Reserved Instance) and Savings Plan recommendations
+# from Azure Advisor and the Reservation Recommendation API.
+#
+# Rate optimization (RI/SP) is the #1 FinOps quick win - typical
+# savings are 30-72% versus pay-as-you-go pricing.
+#
+# Reference: https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations
+###########################################################################
+
+function Get-ReservationAdvice {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $allRecommendations = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # Build subscription ID list and name lookup
+ $subIds = @($Subscriptions | ForEach-Object { $_.Id })
+ $subNameMap = @{}
+ foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id] = $sub.Name }
+
+ # Query Advisor cost recommendations via Resource Graph (single call)
+ $query = @"
+advisorresources
+| where type == 'microsoft.advisor/recommendations'
+| where properties.category == 'Cost'
+| where properties.shortDescription.problem matches regex '(?i)reserv|savings plan|reserved instance'
+ or properties.shortDescription.solution matches regex '(?i)reserv|savings plan|reserved instance'
+| project subscriptionId,
+ shortDescriptionProblem = tostring(properties.shortDescription.problem),
+ shortDescriptionSolution = tostring(properties.shortDescription.solution),
+ impact = tostring(properties.impact),
+ impactedField = tostring(properties.impactedField),
+ impactedValue = tostring(properties.impactedValue),
+ annualSavings = tostring(properties.extendedProperties.annualSavingsAmount),
+ savingsCurrency = tostring(properties.extendedProperties.savingsCurrency),
+ term = tostring(properties.extendedProperties.term),
+ recName = name
+"@
+
+ try {
+ Write-Host " Querying RI/SP recommendations via Resource Graph..." -ForegroundColor Cyan
+ $allRows = [System.Collections.Generic.List[object]]::new()
+ $skipToken = $null
+
+ do {
+ $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -SkipToken $skipToken
+ if ($result -and $result.Data) { foreach ($r in $result.Data) { [void]$allRows.Add($r) } }
+ $skipToken = if ($result) { $result.SkipToken } else { $null }
+ } while ($skipToken)
+
+ Write-Host " Retrieved $($allRows.Count) RI/SP recommendations." -ForegroundColor Cyan
+
+ foreach ($row in $allRows) {
+ $subId = $row.subscriptionId
+ $savings = if ($row.annualSavings) { [math]::Round([double]$row.annualSavings, 2) } else { $null }
+
+ [void]$allRecommendations.Add([PSCustomObject]@{
+ Subscription = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId }
+ SubscriptionId = $subId
+ Problem = $row.shortDescriptionProblem
+ Solution = $row.shortDescriptionSolution
+ Impact = $row.impact
+ Category = 'Reservation / Savings Plan'
+ ResourceType = $row.impactedField
+ ResourceName = $row.impactedValue
+ AnnualSavings = $savings
+ Currency = $row.savingsCurrency
+ Term = $row.term
+ RecommendationId = $row.recName
+ })
+ }
+ } catch {
+ Write-Warning " Advisor Resource Graph query failed: $($_.Exception.Message)"
+ Write-Warning " Falling back to per-subscription REST calls..."
+
+ foreach ($sub in $Subscriptions) {
+ try {
+ $advPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&`$filter=Category eq 'Cost'"
+ $advResp = Invoke-AzRestMethodWithRetry -Path $advPath -Method GET
+ if ($advResp.StatusCode -ne 200) { continue }
+ $advResult = ($advResp.Content | ConvertFrom-Json)
+
+ $riRecs = $advResult.value | Where-Object {
+ $_.properties.shortDescription.problem -match 'reserv|savings plan|reserved instance' -or
+ $_.properties.shortDescription.solution -match 'reserv|savings plan|reserved instance'
+ }
+
+ foreach ($item in $riRecs) {
+ $rec = $item.properties
+ [void]$allRecommendations.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ SubscriptionId = $sub.Id
+ Problem = $rec.shortDescription.problem
+ Solution = $rec.shortDescription.solution
+ Impact = $rec.impact
+ Category = 'Reservation / Savings Plan'
+ ResourceType = $rec.impactedField
+ ResourceName = $rec.impactedValue
+ AnnualSavings = if ($rec.extendedProperties.annualSavingsAmount) {
+ [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2)
+ } else { $null }
+ Currency = $rec.extendedProperties.savingsCurrency
+ Term = $rec.extendedProperties.term
+ RecommendationId = $item.name
+ })
+ }
+ } catch {
+ Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ }
+
+ # -- Also try the Reservation Recommendation API --------------------
+ $reservationRecs = [System.Collections.Generic.List[PSCustomObject]]::new()
+ try {
+ $rrPath = "/providers/Microsoft.Consumption/reservationRecommendations?api-version=2023-05-01&`$filter=properties/scope eq 'Shared' and properties/lookBackPeriod eq 'Last30Days'"
+ $rrResp = Invoke-AzRestMethodWithRetry -Path $rrPath -Method GET
+ if (-not $rrResp -or -not $rrResp.Content) { throw "Reservation recommendation API returned no content (HTTP $($rrResp.StatusCode))" }
+ $rrResult = ($rrResp.Content | ConvertFrom-Json)
+
+ if ($rrResult.value) {
+ foreach ($item in $rrResult.value) {
+ $props = $item.properties
+ [void]$reservationRecs.Add([PSCustomObject]@{
+ ResourceType = $props.resourceType
+ SKU = $props.skuProperties.name
+ RecommendedQty = $props.recommendedQuantity
+ Term = $props.term
+ CostWithoutRI = if ($props.costWithNoReservedInstances) { [math]::Round($props.costWithNoReservedInstances, 2) } else { $null }
+ CostWithRI = if ($props.totalCostWithReservedInstances) { [math]::Round($props.totalCostWithReservedInstances, 2) } else { $null }
+ NetSavings = if ($props.netSavings) { [math]::Round($props.netSavings, 2) } else { $null }
+ Currency = $props.currencyCode
+ Scope = $props.scope
+ LookBackPeriod = $props.lookBackPeriod
+ })
+ }
+ }
+ } catch {
+ Write-Warning "Reservation recommendation API query failed (non-critical): $($_.Exception.Message)"
+ }
+
+ # -- Aggregate savings ----------------------------------------------
+ $totalAnnualSavings = ($allRecommendations | Where-Object { $_.AnnualSavings } |
+ Measure-Object -Property AnnualSavings -Sum).Sum
+
+ return [PSCustomObject]@{
+ AdvisorRecommendations = $allRecommendations
+ ReservationRecommendations = $reservationRecs
+ TotalAdvisorCount = $allRecommendations.Count
+ TotalReservationCount = $reservationRecs.Count
+ EstimatedAnnualSavings = [math]::Round($totalAnnualSavings, 2)
+ Summary = "$($allRecommendations.Count) Advisor + $($reservationRecs.Count) reservation recommendations"
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1
new file mode 100644
index 000000000..6a4e4a4c9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1
@@ -0,0 +1,346 @@
+###########################################################################
+# GET-RESOURCECOSTS.PS1
+# AZURE FINOPS MULTITOOL - Per-Resource Cost Breakdown
+###########################################################################
+# Purpose: Query Cost Management per subscription to retrieve actual and
+# forecasted spend grouped by individual resource.
+###########################################################################
+
+function Get-ResourceCosts {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string]$TenantId,
+
+ [Parameter()]
+ $CostData # Per-sub cost data for forecast ratio distribution
+ )
+
+ # Guard: extract hashtable if pipeline pollution wrapped it in an array
+ if ($CostData -and $CostData -isnot [hashtable]) {
+ $CostData = @($CostData | Where-Object { $_ -is [hashtable] })[-1]
+ }
+ if (-not $CostData) { $CostData = @{} }
+
+ $allRows = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # Friendly resource type map
+ $typeMap = @{
+ 'microsoft.compute/virtualmachines' = 'Virtual Machine'
+ 'microsoft.compute/disks' = 'Managed Disk'
+ 'microsoft.network/loadbalancers' = 'Load Balancer'
+ 'microsoft.network/applicationgateways' = 'App Gateway'
+ 'microsoft.network/azurefirewalls' = 'Azure Firewall'
+ 'microsoft.network/publicipaddresses' = 'Public IP'
+ 'microsoft.network/virtualnetworkgateways' = 'VNet Gateway'
+ 'microsoft.network/virtualnetworks' = 'Virtual Network'
+ 'microsoft.network/privatednszones' = 'Private DNS Zone'
+ 'microsoft.network/networkinterfaces' = 'NIC'
+ 'microsoft.network/networksecuritygroups' = 'NSG'
+ 'microsoft.network/bastionhosts' = 'Bastion'
+ 'microsoft.containerservice/managedclusters' = 'AKS Cluster'
+ 'microsoft.sql/servers' = 'SQL Server'
+ 'microsoft.sql/servers/databases' = 'SQL Database'
+ 'microsoft.storage/storageaccounts' = 'Storage Account'
+ 'microsoft.web/sites' = 'App Service'
+ 'microsoft.web/serverfarms' = 'App Service Plan'
+ 'microsoft.keyvault/vaults' = 'Key Vault'
+ 'microsoft.operationalinsights/workspaces' = 'Log Analytics'
+ 'microsoft.insights/components' = 'App Insights'
+ 'microsoft.recoveryservices/vaults' = 'Recovery Vault'
+ 'microsoft.automation/automationaccounts' = 'Automation Account'
+ 'microsoft.dbformysql/flexibleservers' = 'MySQL Flexible'
+ 'microsoft.dbforpostgresql/flexibleservers' = 'PostgreSQL Flexible'
+ 'microsoft.cosmosdb/databaseaccounts' = 'Cosmos DB'
+ 'microsoft.cache/redis' = 'Redis Cache'
+ 'microsoft.cdn/profiles' = 'CDN / Front Door'
+ 'microsoft.containerregistry/registries' = 'Container Registry'
+ 'microsoft.apimanagement/service' = 'API Management'
+ 'microsoft.eventgrid/topics' = 'Event Grid Topic'
+ 'microsoft.servicebus/namespaces' = 'Service Bus'
+ 'microsoft.logic/workflows' = 'Logic App'
+ 'microsoft.security/pricings' = 'Defender Plan'
+ 'microsoft.hybridcompute/machines' = 'Arc Server'
+ }
+
+ $gotMgData = $false
+
+ # -- Strategy 1: MG-scope query (1-10 API calls instead of 300+) ----
+ if ($TenantId -and (Test-MgCostScope)) {
+ try {
+ Write-Host " Querying resource costs (MG scope)..." -ForegroundColor Cyan
+ $body = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'ResourceId' }
+ @{ type = 'Dimension'; name = 'ResourceGroupName' }
+ )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $mgPath = "/providers/Microsoft.Management/managementGroups/$TenantId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $resp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $body
+
+ if ($resp.StatusCode -eq 200) {
+ $result = ($resp.Content | ConvertFrom-Json)
+ $cols = @{}
+ for ($i = 0; $i -lt $result.properties.columns.Count; $i++) {
+ $cols[$result.properties.columns[$i].name] = $i
+ }
+
+ $page = $result
+ $pageNum = 0
+ do {
+ $pageNum++
+ if ($page.properties.rows) {
+ if ($pageNum -eq 1 -or $pageNum % 3 -eq 0) {
+ Write-Host " Page $pageNum ($($page.properties.rows.Count) rows)..." -ForegroundColor Gray
+ }
+ foreach ($row in $page.properties.rows) {
+ $cost = [math]::Round($row[$cols['Cost']], 2)
+ $currency = $row[$cols['Currency']]
+ $resourceId = $row[$cols['ResourceId']]
+ $rg = $row[$cols['ResourceGroupName']]
+
+ $resType = 'Unknown'
+ $resName = $resourceId
+ if ($resourceId -match '/providers/(.+)/([^/]+)$') {
+ $providerType = $Matches[1].ToLower()
+ $resName = $Matches[2]
+ $resType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace 'microsoft\.', '' }
+ }
+
+ [void]$allRows.Add([PSCustomObject]@{
+ Subscription = ''
+ ResourceGroup = $rg
+ ResourceType = $resType
+ ResourcePath = $resourceId
+ Actual = $cost
+ Forecast = $cost
+ Currency = $currency
+ })
+ }
+ }
+ if ($page.properties.nextLink) {
+ $nextUri = [System.Uri]$page.properties.nextLink
+ $nResp = Invoke-AzRestMethodWithRetry -Path $nextUri.PathAndQuery -Method GET
+ if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) }
+ else { break }
+ } else { break }
+ } while ($true)
+
+ if ($allRows.Count -gt 0) {
+ $gotMgData = $true
+ Write-Host " MG scope: $($allRows.Count) resources across $pageNum page(s)" -ForegroundColor Green
+
+ # Populate subscription names from ARM resource ID
+ $subNameMap = @{}
+ foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id.ToLower()] = $sub.Name }
+ foreach ($r in $allRows) {
+ if ($r.ResourcePath -match '/subscriptions/([^/]+)/') {
+ $sid = $Matches[1].ToLower()
+ $r.Subscription = if ($subNameMap.ContainsKey($sid)) { $subNameMap[$sid] } else { $sid }
+ }
+ }
+
+ # Apply forecast ratios from CostData (actual + forecast per sub)
+ if ($CostData) {
+ $ratios = @{}
+ foreach ($entry in $CostData.GetEnumerator()) {
+ $a = $entry.Value.Actual
+ $f = $entry.Value.Forecast
+ if ($a -gt 0 -and $f -gt $a) { $ratios[$entry.Key.ToLower()] = $f / $a }
+ }
+ foreach ($r in $allRows) {
+ if ($r.ResourcePath -match '/subscriptions/([^/]+)/') {
+ $sid = $Matches[1].ToLower()
+ if ($ratios.ContainsKey($sid)) {
+ $r.Forecast = [math]::Round($r.Actual * $ratios[$sid], 2)
+ }
+ }
+ }
+ }
+ }
+ } else {
+ if ($resp.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed }
+ Write-Warning " MG-scope resource cost query returned HTTP $($resp.StatusCode)"
+ }
+ } catch {
+ Write-Warning " MG-scope resource cost query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Strategy 2: Per-subscription fallback (only if MG scope failed) -
+ if (-not $gotMgData) {
+ $subCount = $Subscriptions.Count
+ $skipForecast = ($subCount -gt 50) # For large tenants, skip per-sub forecast to halve API calls
+ if ($skipForecast) {
+ Write-Host " Large tenant ($subCount subs): skipping per-resource forecast to reduce API calls" -ForegroundColor Yellow
+ }
+
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Querying resource costs ($i/$subCount subs)..."
+ }
+ }
+ $basePath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement"
+
+ # -- Actual cost grouped by resource ----------------------------
+ $actualMap = @{}
+ try {
+ Write-Host " Querying resource costs for $($sub.Name)..." -ForegroundColor Cyan
+ $body = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'ResourceId' }
+ @{ type = 'Dimension'; name = 'ResourceGroupName' }
+ )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $resp = Invoke-AzRestMethodWithRetry -Path "$basePath/query?api-version=2023-11-01" -Method POST -Payload $body
+
+ if ($resp.StatusCode -eq 200) {
+ $result = ($resp.Content | ConvertFrom-Json)
+
+ # Build column index from response metadata (same for all pages)
+ $cols = @{}
+ for ($i = 0; $i -lt $result.properties.columns.Count; $i++) {
+ $cols[$result.properties.columns[$i].name] = $i
+ }
+
+ # Process all pages (Cost Management API paginates at ~5000 rows)
+ $page = $result
+ do {
+ if ($page.properties.rows) {
+ foreach ($row in $page.properties.rows) {
+ $cost = [math]::Round($row[$cols['Cost']], 2)
+ $currency = $row[$cols['Currency']]
+ $resourceId = $row[$cols['ResourceId']]
+ $rg = $row[$cols['ResourceGroupName']]
+
+ # Extract resource type from ARM ID
+ $resType = 'Unknown'
+ $resName = $resourceId
+ if ($resourceId -match '/providers/(.+)/([^/]+)$') {
+ $providerType = $Matches[1].ToLower()
+ $resName = $Matches[2]
+ $resType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace 'microsoft\.', '' }
+ }
+
+ $actualMap[$resourceId] = [PSCustomObject]@{
+ Subscription = $sub.Name
+ ResourceGroup = $rg
+ ResourceType = $resType
+ ResourcePath = $resourceId
+ Actual = $cost
+ Forecast = $cost
+ Currency = $currency
+ }
+ }
+ }
+ # Follow pagination link if present
+ if ($page.properties.nextLink) {
+ $uri = [System.Uri]$page.properties.nextLink
+ $nResp = Invoke-AzRestMethodWithRetry -Path $uri.PathAndQuery -Method GET
+ if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) }
+ else { break }
+ } else { break }
+ } while ($true)
+ }
+ } catch {
+ Write-Warning " Resource cost query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+
+ # -- Forecast: use subscription-level forecast ratio -------------
+ # The forecast API does not reliably support ResourceId grouping,
+ # so we get the sub-level forecast and distribute proportionally.
+ # For large tenants (50+ subs), skip per-sub forecast API calls
+ # and use CostData ratios if available.
+ $subTotalActual = 0
+ foreach ($entry in $actualMap.Values) { $subTotalActual += $entry.Actual }
+
+ $subForecast = $subTotalActual # default: same as actual
+
+ # Use CostData ratio if available (avoids extra API call)
+ if ($CostData -and $CostData.ContainsKey($sub.Id)) {
+ $cd = $CostData[$sub.Id]
+ if ($cd.Forecast -gt $cd.Actual -and $cd.Actual -gt 0) {
+ $subForecast = $subTotalActual * ($cd.Forecast / $cd.Actual)
+ }
+ }
+ elseif (-not $skipForecast) {
+ # Only call forecast API for small tenants without CostData
+ try {
+ $now = Get-Date
+ $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1)
+
+ $fBody = @{
+ type = 'Usage'
+ timeframe = 'Custom'
+ timePeriod = @{
+ from = $now.ToString('yyyy-MM-dd')
+ to = $monthEnd.ToString('yyyy-MM-dd')
+ }
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ }
+ includeActualCost = $true
+ includeFreshPartialCost = $false
+ } | ConvertTo-Json -Depth 10
+
+ $fResp = Invoke-AzRestMethodWithRetry -Path "$basePath/forecast?api-version=2023-11-01" -Method POST -Payload $fBody
+
+ if ($fResp.StatusCode -eq 200) {
+ $fResult = ($fResp.Content | ConvertFrom-Json)
+ if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) {
+ $forecastTotal = 0
+ foreach ($row in $fResult.properties.rows) {
+ $forecastTotal += [double]$row[0]
+ }
+ $subForecast = [math]::Round($forecastTotal, 2)
+ }
+ }
+ } catch {
+ # Forecast not available for all account types
+ }
+ }
+
+ # Apply forecast ratio proportionally to each resource
+ if ($subTotalActual -gt 0 -and $subForecast -gt $subTotalActual) {
+ $ratio = $subForecast / $subTotalActual
+ foreach ($entry in $actualMap.Values) {
+ $entry.Forecast = [math]::Round($entry.Actual * $ratio, 2)
+ }
+ }
+
+ # Collect rows from this sub
+ foreach ($entry in $actualMap.Values) {
+ [void]$allRows.Add($entry)
+ }
+ }
+ } # end per-sub fallback
+
+ return $allRows
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1
new file mode 100644
index 000000000..745d26ed3
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1
@@ -0,0 +1,269 @@
+###########################################################################
+# GET-SAVINGSREALIZED.PS1
+# AZURE FINOPS MULTITOOL - Savings Already Realized from Commitments
+###########################################################################
+# Purpose: Calculate how much existing RIs, Savings Plans, and AHB have
+# already saved vs pay-as-you-go. This is the "value delivered"
+# metric that FinOps teams report to leadership.
+###########################################################################
+
+function Get-SavingsRealized {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object]$CommitmentData
+ )
+
+ Write-Host " Calculating savings already realized..." -ForegroundColor Cyan
+
+ $riSavings = 0
+ $spSavings = 0
+ $ahbSavings = 0
+ $details = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # -- Short-circuit: skip RI/SP queries if no commitments exist -------
+ $hasCommitments = $true
+ if ($CommitmentData -and $CommitmentData.PSObject.Properties['HasData']) {
+ if (-not $CommitmentData.HasData) {
+ $hasCommitments = $false
+ Write-Host " No reservations or savings plans detected — skipping commitment savings queries" -ForegroundColor DarkGray
+ }
+ }
+
+ $gotMgData = $false
+
+ # -- Strategy 1: MG-scope queries (2 API calls instead of N*2) ------
+ if ($hasCommitments -and $TenantId -and (Test-MgCostScope)) {
+ try {
+ Write-Host " Calculating savings (MG scope)..." -ForegroundColor Cyan
+ $mgPath = "/providers/Microsoft.Management/managementGroups/$TenantId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+
+ # ActualCost by ChargeType
+ $actualBody = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @( @{ type = 'Dimension'; name = 'ChargeType' } )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $actualResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $actualBody
+ if ($actualResp.StatusCode -in @(401, 403)) {
+ Set-MgCostScopeFailed
+ throw "MG-scope savings query returned HTTP $($actualResp.StatusCode)"
+ }
+ if ($actualResp.StatusCode -eq 200) {
+ $actualResult = ($actualResp.Content | ConvertFrom-Json)
+ if ($actualResult.properties.rows) {
+ foreach ($row in $actualResult.properties.rows) {
+ $chargeType = $row[1]
+ $cost = [math]::Round([double]$row[0], 2)
+ if ($chargeType -match 'UnusedReservation') {
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = 'All (MG scope)'
+ Category = 'Unused Reservation'
+ Amount = $cost
+ Type = 'Waste'
+ })
+ }
+ }
+ }
+ }
+
+ # AmortizedCost by PricingModel
+ $amortBody = @{
+ type = 'AmortizedCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
+ grouping = @( @{ type = 'Dimension'; name = 'PricingModel' } )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $amortResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $amortBody
+ if ($amortResp.StatusCode -eq 200) {
+ $amortResult = ($amortResp.Content | ConvertFrom-Json)
+ if ($amortResult.properties.rows) {
+ foreach ($row in $amortResult.properties.rows) {
+ $pricingModel = $row[1]
+ $cost = [math]::Round([double]$row[0], 2)
+ if ($pricingModel -match 'Reservation') {
+ $riSavings += $cost * 0.4
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = 'All (MG scope)'
+ Category = 'Reservation Benefit'
+ Amount = $cost
+ Type = 'Commitment'
+ })
+ }
+ elseif ($pricingModel -match 'SavingsPlan') {
+ $spSavings += $cost * 0.25
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = 'All (MG scope)'
+ Category = 'Savings Plan Benefit'
+ Amount = $cost
+ Type = 'Commitment'
+ })
+ }
+ }
+ }
+ }
+
+ $gotMgData = $true
+ Write-Host " MG scope savings calculated (2 API calls)" -ForegroundColor Green
+ } catch {
+ Write-Warning " MG-scope savings query failed: $($_.Exception.Message)"
+ }
+ }
+
+ # -- Strategy 2: Per-subscription fallback ---------------------------
+ if ($hasCommitments -and -not $gotMgData) {
+ # -- Step 1: Query amortized vs actual to find RI/SP benefit amounts --
+ # The difference between ActualCost and AmortizedCost reveals commitment savings
+ $subCount = $Subscriptions.Count
+ $i = 0
+ foreach ($sub in $Subscriptions) {
+ $i++
+ if ($subCount -gt 5 -and ($i -eq 1 -or $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) {
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Calculating savings ($i/$subCount subs)..."
+ }
+ }
+ try {
+ # Get ActualCost MonthToDate
+ $actualBody = @{
+ type = 'ActualCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'ChargeType' }
+ )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
+ $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $actualBody
+
+ if ($actualResp.StatusCode -eq 200) {
+ $actualResult = ($actualResp.Content | ConvertFrom-Json)
+ if ($actualResult.properties.rows) {
+ foreach ($row in $actualResult.properties.rows) {
+ $chargeType = $row[1]
+ $cost = [math]::Round([double]$row[0], 2)
+
+ # RI/SP purchases show as separate charge types
+ if ($chargeType -match 'UnusedReservation') {
+ # This is wasted money — unused RI capacity
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ Category = 'Unused Reservation'
+ Amount = $cost
+ Type = 'Waste'
+ })
+ }
+ }
+ }
+ }
+
+ # Get benefit usage via the reservation transactions or amortized view
+ $amortBody = @{
+ type = 'AmortizedCost'
+ timeframe = 'MonthToDate'
+ dataset = @{
+ granularity = 'None'
+ aggregation = @{
+ totalCost = @{ name = 'Cost'; function = 'Sum' }
+ }
+ grouping = @(
+ @{ type = 'Dimension'; name = 'PricingModel' }
+ )
+ }
+ } | ConvertTo-Json -Depth 10
+
+ $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $amortBody
+ if ($amortResp.StatusCode -eq 200) {
+ $amortResult = ($amortResp.Content | ConvertFrom-Json)
+ if ($amortResult.properties.rows) {
+ foreach ($row in $amortResult.properties.rows) {
+ $pricingModel = $row[1]
+ $cost = [math]::Round([double]$row[0], 2)
+
+ if ($pricingModel -match 'Reservation') {
+ # Amortized RI cost — the actual RI spend
+ $riSavings += $cost * 0.4 # Approximate: RIs typically save ~40% vs PAYG
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ Category = 'Reservation Benefit'
+ Amount = $cost
+ Type = 'Commitment'
+ })
+ }
+ elseif ($pricingModel -match 'SavingsPlan') {
+ $spSavings += $cost * 0.25 # Approximate: SPs save ~25% on average
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = $sub.Name
+ Category = 'Savings Plan Benefit'
+ Amount = $cost
+ Type = 'Commitment'
+ })
+ }
+ }
+ }
+ }
+ } catch {
+ Write-Warning " Savings query failed for $($sub.Name): $($_.Exception.Message)"
+ }
+ }
+ } # end per-sub fallback
+
+ # -- Step 2: AHB savings estimate from Resource Graph -----------------
+ try {
+ $ahbQuery = @"
+resources
+| where type =~ 'microsoft.compute/virtualmachines'
+| where properties.licenseType == 'Windows_Server'
+| summarize AHBVMs = count()
+"@
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $ahbResult = Search-AzGraphSafe -Query $ahbQuery -Subscription $subIds
+ if ($ahbResult.Data -and $ahbResult.Data.Count -gt 0) {
+ $ahbVMCount = $ahbResult.Data[0].AHBVMs
+ # Average D2s v3 Windows license cost is ~$100/mo; AHB saves ~$50/mo per VM
+ $ahbSavings = $ahbVMCount * 50 # Conservative monthly estimate
+ [void]$details.Add([PSCustomObject]@{
+ Subscription = 'All'
+ Category = 'Azure Hybrid Benefit (VMs)'
+ Amount = $ahbSavings
+ Type = 'AHB'
+ })
+ }
+ } catch {
+ Write-Warning " AHB count query failed: $($_.Exception.Message)"
+ }
+
+ $totalMonthly = [math]::Round($riSavings + $spSavings + $ahbSavings, 2)
+ $totalAnnual = [math]::Round($totalMonthly * 12, 2)
+
+ return [PSCustomObject]@{
+ RISavingsMonthly = [math]::Round($riSavings, 2)
+ SPSavingsMonthly = [math]::Round($spSavings, 2)
+ AHBSavingsMonthly = [math]::Round($ahbSavings, 2)
+ TotalMonthly = $totalMonthly
+ TotalAnnual = $totalAnnual
+ Details = @($details)
+ HasData = ($totalMonthly -gt 0 -or $details.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1
new file mode 100644
index 000000000..25bf6faa9
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1
@@ -0,0 +1,123 @@
+###########################################################################
+# GET-STORAGETIERADVICE.PS1
+# AZURE FINOPS MULTITOOL - Storage Tier Optimization
+###########################################################################
+# Purpose: Identify storage accounts with hot-tier blob containers that
+# have not been accessed recently and would benefit from moving
+# to Cool or Archive tier to reduce costs.
+###########################################################################
+
+function Get-StorageTierAdvice {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ Write-Host " Scanning storage tier optimization opportunities..." -ForegroundColor Cyan
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+ $results = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+ # -- 1: Find all storage accounts on Hot default tier -----------------
+ try {
+ $query = @"
+resources
+| where type =~ 'microsoft.storage/storageaccounts'
+| where properties.accessTier =~ 'Hot' or isnull(properties.accessTier)
+| project name, resourceGroup, subscriptionId, location,
+ kind, sku = sku.name,
+ accessTier = tostring(properties.accessTier),
+ creationTime = properties.creationTime,
+ blobCount = properties.primaryEndpoints.blob
+"@
+ $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000
+ $hotAccounts = if ($result) { @($result.Data) } else { @() }
+ Write-Host " Hot-tier storage accounts: $($hotAccounts.Count)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " Storage account query failed: $($_.Exception.Message)"
+ $hotAccounts = @()
+ }
+
+ # -- 2: For each hot account, check last access metrics ---------------
+ $token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').Token
+ $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' }
+ $now = (Get-Date).ToUniversalTime()
+ $thirtyDaysAgo = $now.AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ')
+ $nowStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ')
+
+ foreach ($sa in $hotAccounts) {
+ $scope = "/subscriptions/$($sa.subscriptionId)/resourceGroups/$($sa.resourceGroup)/providers/Microsoft.Storage/storageAccounts/$($sa.name)"
+ try {
+ # Query transaction count (Blob service) over last 30 days
+ $metricUri = "https://management.azure.com$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Transactions×pan=$thirtyDaysAgo/$nowStr&aggregation=Total&interval=P30D"
+ $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop
+ $metricData = ($resp.Content | ConvertFrom-Json)
+
+ $totalTx = 0
+ if ($metricData.value -and $metricData.value.Count -gt 0) {
+ foreach ($ts in $metricData.value[0].timeseries) {
+ foreach ($dp in $ts.data) {
+ if ($dp.total) { $totalTx += $dp.total }
+ }
+ }
+ }
+
+ # Also query used capacity
+ $capacityUri = "https://management.azure.com$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=BlobCapacity×pan=$thirtyDaysAgo/$nowStr&aggregation=Average&interval=P30D"
+ $capResp = Invoke-WebRequest -Uri $capacityUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction SilentlyContinue
+ $capacityBytes = 0
+ if ($capResp) {
+ $capData = ($capResp.Content | ConvertFrom-Json)
+ if ($capData.value -and $capData.value.Count -gt 0) {
+ foreach ($ts in $capData.value[0].timeseries) {
+ foreach ($dp in $ts.data) {
+ if ($dp.average -and $dp.average -gt $capacityBytes) { $capacityBytes = $dp.average }
+ }
+ }
+ }
+ }
+
+ $capacityGB = [math]::Round($capacityBytes / 1GB, 2)
+ $recommendation = $null
+ $estSavingsPct = 0
+
+ if ($totalTx -eq 0 -and $capacityGB -gt 0) {
+ $recommendation = 'Archive'
+ $estSavingsPct = 90
+ } elseif ($totalTx -lt 100 -and $capacityGB -gt 0) {
+ $recommendation = 'Archive'
+ $estSavingsPct = 90
+ } elseif ($totalTx -lt 1000 -and $capacityGB -gt 1) {
+ $recommendation = 'Cool'
+ $estSavingsPct = 50
+ }
+
+ if ($recommendation) {
+ [void]$results.Add([PSCustomObject]@{
+ StorageAccount = $sa.name
+ ResourceGroup = $sa.resourceGroup
+ SubscriptionId = $sa.subscriptionId
+ Location = $sa.location
+ CurrentTier = if ($sa.accessTier) { $sa.accessTier } else { 'Hot (default)' }
+ SKU = $sa.sku
+ CapacityGB = $capacityGB
+ Transactions30d = $totalTx
+ Recommendation = $recommendation
+ EstSavingsPct = $estSavingsPct
+ })
+ }
+ } catch {
+ # Metrics not available (classic account, no blob service, etc.) — skip
+ }
+ }
+
+ Write-Host " Storage tier recommendations: $($results.Count)" -ForegroundColor Gray
+
+ [PSCustomObject]@{
+ Recommendations = @($results)
+ TotalHotAccounts = $hotAccounts.Count
+ Count = $results.Count
+ HasData = ($results.Count -gt 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1
new file mode 100644
index 000000000..ca3434cfc
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1
@@ -0,0 +1,200 @@
+###########################################################################
+# GET-TAGINVENTORY.PS1
+# AZURE FINOPS MULTITOOL - Tag Inventory Across the Tenant
+###########################################################################
+# Purpose: Use Azure Resource Graph to discover every tag name and value
+# in use across all subscriptions, along with resource counts
+# and resource types per tag.
+#
+# This is the "Understand" FinOps pillar - you can't allocate costs you
+# can't see, and untagged resources are invisible to chargeback.
+###########################################################################
+
+function Get-TagInventory {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$Subscriptions
+ )
+
+ $subIds = $Subscriptions | ForEach-Object { $_.Id }
+
+ # -- Query 1: Tag names, values, and counts -------------------------
+ try {
+ Write-Host " Scanning tag inventory via Resource Graph..." -ForegroundColor Cyan
+ $tagQuery = @"
+resources
+| union resourcecontainers
+| mvexpand tags
+| extend tagName = tostring(bag_keys(tags)[0])
+| extend tagValue = tostring(tags[tagName])
+| where isnotempty(tagName)
+| summarize ResourceCount = count(), ResourceTypes = make_set(type) by tagName, tagValue
+| order by tagName asc, ResourceCount desc
+"@
+
+ $allResults = @()
+ $skipToken = $null
+
+ do {
+ $result = Search-AzGraphSafe -Query $tagQuery -Subscription $subIds -First 1000 -SkipToken $skipToken
+ if (-not $result) { break }
+ $allResults += $result.Data
+ $skipToken = $result.SkipToken
+ } while ($skipToken)
+
+ } catch {
+ Write-Warning "Tag inventory query failed: $($_.Exception.Message)"
+ $allResults = @()
+ }
+
+ # -- Query 2: Untagged resource count (via REST to avoid runspace issues with single-row aggregates)
+ try {
+ $countBody = @{
+ subscriptions = @($subIds)
+ query = "resources | where isnull(tags) or tags == '{}' | summarize UntaggedCount = count()"
+ } | ConvertTo-Json -Depth 5
+ $countResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $countBody
+ if ($countResp.StatusCode -eq 200) {
+ $countData = ($countResp.Content | ConvertFrom-Json)
+ if ($countData.data -and $countData.data.Count -gt 0) {
+ $untaggedCount = [int]$countData.data[0].UntaggedCount
+ }
+ }
+ } catch {
+ Write-Warning "Untagged resource count failed: $($_.Exception.Message)"
+ }
+
+ # -- Query 4: Untagged resource details (paginate all) ----------------
+ $untaggedResources = @()
+ try {
+ $untaggedDetailQuery = @"
+resources
+| where isnull(tags) or tags == '{}'
+| project name, type, resourceGroup, subscriptionId, location
+| order by type asc, name asc
+"@
+ $allUntagged = @()
+ $udSkipToken = $null
+ do {
+ $udResult = Search-AzGraphSafe -Query $untaggedDetailQuery -Subscription $subIds -First 1000 -SkipToken $udSkipToken
+ if (-not $udResult -or -not $udResult.Data) { break }
+ $allUntagged += $udResult.Data
+ $udSkipToken = $udResult.SkipToken
+ if ($allUntagged.Count % 2000 -eq 0) {
+ Write-Host " Loaded $($allUntagged.Count) untagged resources so far..." -ForegroundColor Gray
+ }
+ } while ($udSkipToken)
+
+ if ($allUntagged.Count -gt 0) {
+ # Map subscription IDs to names
+ $subNameMap = @{}
+ foreach ($s in $Subscriptions) { $subNameMap[$s.Id] = $s.Name }
+ $untaggedResources = @($allUntagged | ForEach-Object {
+ [PSCustomObject]@{
+ ResourceName = $_.name
+ ResourceType = $_.type
+ ResourceGroup = $_.resourceGroup
+ Subscription = if ($subNameMap.ContainsKey($_.subscriptionId)) { $subNameMap[$_.subscriptionId] } else { $_.subscriptionId }
+ Location = $_.location
+ }
+ })
+ Write-Host " Total untagged resources loaded: $($untaggedResources.Count)" -ForegroundColor Cyan
+ }
+ } catch {
+ Write-Warning "Untagged resource detail query failed: $($_.Exception.Message)"
+ }
+
+ # -- Query 3: Total resource count (via REST to avoid runspace issues with single-row aggregates)
+ $totalCount = 0
+ try {
+ $totalBody = @{
+ subscriptions = @($subIds)
+ query = "resources | summarize TotalCount = count()"
+ } | ConvertTo-Json -Depth 5
+ $totalResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $totalBody
+ if ($totalResp.StatusCode -eq 200) {
+ $totalData = ($totalResp.Content | ConvertFrom-Json)
+ if ($totalData.data -and $totalData.data.Count -gt 0) {
+ $totalCount = [int]$totalData.data[0].TotalCount
+ }
+ }
+ } catch {
+ Write-Warning "Total resource count failed: $($_.Exception.Message)"
+ }
+
+ # Fallback: derive counts from detail data if REST queries failed
+ if ($untaggedCount -eq 0 -and $untaggedResources.Count -gt 0) {
+ $untaggedCount = $untaggedResources.Count
+ Write-Host " Using detail query count as fallback: $untaggedCount untagged" -ForegroundColor Yellow
+ }
+
+ # -- Build summary --------------------------------------------------
+ $tagNames = @{}
+ foreach ($row in $allResults) {
+ $name = $row.tagName
+ if (-not $tagNames.ContainsKey($name)) {
+ $tagNames[$name] = @{ Values = @(); TotalResources = 0 }
+ }
+ $tagNames[$name].Values += [PSCustomObject]@{
+ Value = $row.tagValue
+ ResourceCount = $row.ResourceCount
+ ResourceTypes = $row.ResourceTypes
+ }
+ $tagNames[$name].TotalResources += $row.ResourceCount
+ }
+
+ # -- Query 5: Tag locations (which subscriptions + RGs each tag is on)
+ $tagLocations = @{}
+ try {
+ $subNameMap = @{}
+ foreach ($s in $Subscriptions) { $subNameMap[$s.Id] = $s.Name }
+
+ $locQuery = @"
+resources
+| union resourcecontainers
+| mvexpand tags
+| extend tagName = tostring(bag_keys(tags)[0])
+| where isnotempty(tagName)
+| summarize ResourceCount = count() by tagName, subscriptionId, resourceGroup
+| order by tagName asc, ResourceCount desc
+"@
+ $locResults = @()
+ $locSkip = $null
+ do {
+ $locResult = Search-AzGraphSafe -Query $locQuery -Subscription $subIds -First 1000 -SkipToken $locSkip
+ if (-not $locResult) { break }
+ $locResults += $locResult.Data
+ $locSkip = $locResult.SkipToken
+ } while ($locSkip)
+
+ foreach ($row in $locResults) {
+ $name = $row.tagName
+ if (-not $tagLocations.ContainsKey($name)) {
+ $tagLocations[$name] = [System.Collections.Generic.List[string]]::new()
+ }
+ $subName = if ($subNameMap.ContainsKey($row.subscriptionId)) { $subNameMap[$row.subscriptionId] } else { $row.subscriptionId }
+ $loc = "$subName / $($row.resourceGroup)"
+ if ($loc -notin $tagLocations[$name]) {
+ [void]$tagLocations[$name].Add($loc)
+ }
+ }
+ } catch {
+ Write-Warning "Tag location query failed: $($_.Exception.Message)"
+ }
+
+ $taggedCount = $totalCount - $untaggedCount
+ $tagCoverage = if ($totalCount -gt 0) { [math]::Round(($taggedCount / $totalCount) * 100, 1) } else { 0 }
+
+ return [PSCustomObject]@{
+ TagNames = $tagNames
+ TagCount = $tagNames.Count
+ TagLocations = $tagLocations
+ TotalResources = $totalCount
+ TaggedCount = $taggedCount
+ UntaggedCount = $untaggedCount
+ TagCoverage = $tagCoverage
+ UntaggedResources = $untaggedResources
+ RawResults = $allResults
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1
new file mode 100644
index 000000000..54e776c99
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1
@@ -0,0 +1,170 @@
+###########################################################################
+# GET-TAGRECOMMENDATIONS.PS1
+# AZURE FINOPS MULTITOOL - Tag Recommendations (MS Best Practices)
+###########################################################################
+# Purpose: Compare the customer's actual tags against Microsoft's
+# recommended tagging strategy from the Cloud Adoption Framework.
+#
+# Reference:
+# https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging
+# https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/govern/guides/standard/prescriptive-guidance#resource-tagging
+###########################################################################
+
+function Get-TagRecommendations {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [hashtable]$ExistingTags, # Keys = tag names currently in use
+
+ [hashtable]$TagLocations = @{} # Keys = tag names, Values = list of "Sub / RG" strings
+ )
+
+ # Microsoft Cloud Adoption Framework recommended tags for FinOps allocation
+ # These 7 tags map directly to CAF categories and FinOps allocation needs
+ $recommendedTags = @(
+ [PSCustomObject]@{
+ TagName = 'CostCenter'
+ Category = 'Accounting'
+ Purpose = 'Financial allocation - maps resources to internal cost centers for chargeback/showback'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 3
+ Example = 'CostCenter: CC-12345'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'BusinessUnit'
+ Category = 'Ownership'
+ Purpose = 'Org-level chargeback - enables showback/chargeback at department level'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 3
+ Example = 'BusinessUnit: Finance | Engineering | Marketing'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'ApplicationName'
+ Category = 'Functional'
+ Purpose = 'Product/service cost mapping - groups resources by the application they support'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 2
+ Example = 'ApplicationName: HRPortal | ERP | WebFrontend'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'WorkloadName'
+ Category = 'Functional'
+ Purpose = 'Workload attribution - identifies the workload a resource belongs to'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 1
+ Example = 'WorkloadName: PaymentProcessing | DataPipeline'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'OpsTeam'
+ Category = 'Ownership'
+ Purpose = 'Accountability for spend - which team owns and operates the resource'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 1
+ Example = 'OpsTeam: Platform-Infra | App-TeamA | SRE'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'Criticality'
+ Category = 'Classification'
+ Purpose = 'Prioritization of spend - business impact level drives optimization boundaries'
+ Pillar = 'Optimize'
+ Priority = 'Required'
+ Weight = 1
+ Example = 'Criticality: Mission-Critical | Business-Critical | Low'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ [PSCustomObject]@{
+ TagName = 'DataClassification'
+ Category = 'Classification'
+ Purpose = 'Compliance-driven allocation - data sensitivity determines governance requirements'
+ Pillar = 'Understand'
+ Priority = 'Required'
+ Weight = 1
+ Example = 'DataClassification: Confidential | Public | Internal'
+ Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags'
+ }
+ )
+
+ # Check which recommended tags are present / missing
+ $existingNames = $ExistingTags.Keys | ForEach-Object { $_.ToLower() }
+
+ $analysis = foreach ($rec in $recommendedTags) {
+ $found = $existingNames -contains $rec.TagName.ToLower()
+
+ # Also check common variations
+ $variations = switch ($rec.TagName) {
+ 'CostCenter' { @('cost-center', 'costcenter', 'cost_center', 'cc') }
+ 'BusinessUnit' { @('bu', 'businessunit', 'business-unit', 'department', 'dept') }
+ 'ApplicationName' { @('applicationname', 'application', 'app', 'appname', 'app-name', 'workload') }
+ 'WorkloadName' { @('workloadname', 'workload', 'workload-name', 'workload_name') }
+ 'OpsTeam' { @('opsteam', 'ops-team', 'ops_team', 'operationsteam', 'team', 'owner', 'technicalowner') }
+ 'Criticality' { @('criticality', 'sla', 'tier', 'importance') }
+ 'DataClassification' { @('dataclassification', 'data-classification', 'data_classification', 'classification') }
+ default { @() }
+ }
+ $foundVariation = $existingNames | Where-Object { $_ -in $variations } | Select-Object -First 1
+
+ $status = if ($found) { 'Present' }
+ elseif ($foundVariation) { "Variation found: $foundVariation" }
+ else { 'Missing' }
+
+ # Build location string from TagLocations
+ $matchedName = if ($found) { $rec.TagName }
+ elseif ($foundVariation) { $foundVariation }
+ else { $null }
+
+ # Resolve original-case tag name from ExistingTags for accurate removal
+ $actualTagName = $null
+ if ($matchedName) {
+ $actualTagName = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $matchedName.ToLower() } | Select-Object -First 1
+ if (-not $actualTagName) { $actualTagName = $matchedName }
+ }
+
+ $locationStr = ''
+ if ($matchedName) {
+ # Case-insensitive lookup in TagLocations hashtable
+ $locKey = $TagLocations.Keys | Where-Object { $_.ToLower() -eq $matchedName.ToLower() } | Select-Object -First 1
+ if ($locKey -and $TagLocations[$locKey]) {
+ $locs = @($TagLocations[$locKey])
+ if ($locs.Count -le 3) {
+ $locationStr = $locs -join '; '
+ } else {
+ $locationStr = ($locs[0..2] -join '; ') + " (+$($locs.Count - 3) more)"
+ }
+ }
+ }
+
+ [PSCustomObject]@{
+ TagName = $rec.TagName
+ ActualTagName = $actualTagName
+ Status = $status
+ Priority = $rec.Priority
+ Pillar = $rec.Pillar
+ Purpose = $rec.Purpose
+ Location = $locationStr
+ Example = $rec.Example
+ Reference = $rec.Reference
+ }
+ }
+
+ $missingRequired = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Required' })
+ $missingRecommended = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Recommended' })
+ $present = @($analysis | Where-Object { $_.Status -ne 'Missing' })
+
+ return [PSCustomObject]@{
+ Analysis = $analysis
+ MissingRequired = $missingRequired
+ MissingRecommended = $missingRecommended
+ Present = $present
+ CompliancePercent = [math]::Round(($present.Count / $analysis.Count) * 100, 0)
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1
new file mode 100644
index 000000000..9f92529a8
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1
@@ -0,0 +1,115 @@
+###########################################################################
+# GET-TENANTHIERARCHY.PS1
+# AZURE FINOPS MULTITOOL - Management Group & Subscription Hierarchy
+###########################################################################
+# Purpose: Retrieve the full management group tree with subscriptions
+# nested under their parent groups.
+###########################################################################
+
+function Get-TenantHierarchy {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
+ [string]$TenantId,
+
+ [Parameter()]
+ [object[]]$Subscriptions,
+
+ [Parameter()]
+ [int]$TimeoutSeconds = 60
+ )
+
+ try {
+ # Run in a background runspace with timeout to prevent UI freeze
+ $rs = [runspacefactory]::CreateRunspace()
+ $rs.Open()
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ [void]$ps.AddScript({
+ param($tid)
+ Get-AzManagementGroup -GroupId $tid -Expand -Recurse -ErrorAction Stop
+ }).AddArgument($TenantId)
+
+ $asyncResult = $ps.BeginInvoke()
+
+ # Wait for runspace — uses DispatcherFrame when WPF is loaded, else Start-Sleep
+ Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds
+
+ if ($asyncResult.IsCompleted) {
+ $rootGroup = $ps.EndInvoke($asyncResult)
+ if ($ps.Streams.Error.Count -gt 0) {
+ throw $ps.Streams.Error[0].Exception
+ }
+ $ps.Dispose(); $rs.Close()
+
+ if ($rootGroup) {
+ $actual = if ($rootGroup -is [array]) { $rootGroup[0] } else { $rootGroup }
+ $subMap = @{}
+ Build-SubMap -Group $actual -Map ([ref]$subMap)
+ return [PSCustomObject]@{
+ RootGroup = $actual
+ SubscriptionMap = $subMap
+ }
+ }
+ }
+ else {
+ # Timed out — stop and fall through to fallback
+ $ps.Stop()
+ $ps.Dispose(); $rs.Close()
+ Write-Warning "Management group hierarchy timed out after $TimeoutSeconds seconds. Using flat subscription list."
+ }
+
+ # Fallback
+ $subs = if ($Subscriptions) { @($Subscriptions) } else {
+ @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' })
+ }
+ $fallbackRoot = [PSCustomObject]@{
+ DisplayName = "Tenant Root"
+ Name = $TenantId
+ Children = @()
+ }
+ return [PSCustomObject]@{
+ RootGroup = $fallbackRoot
+ SubscriptionMap = @{}
+ FlatSubs = $subs
+ }
+ }
+ catch {
+ Write-Warning "Failed to load management group hierarchy: $($_.Exception.Message)"
+ Write-Warning "Falling back to flat subscription list."
+
+ $subs = if ($Subscriptions) { @($Subscriptions) } else {
+ @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' })
+ }
+ $fallbackRoot = [PSCustomObject]@{
+ DisplayName = "Tenant Root"
+ Name = $TenantId
+ Children = @()
+ }
+
+ return [PSCustomObject]@{
+ RootGroup = $fallbackRoot
+ SubscriptionMap = @{}
+ FlatSubs = $subs
+ }
+ }
+}
+
+function Build-SubMap {
+ param(
+ [object]$Group,
+ [ref]$Map
+ )
+
+ if ($Group.Children) {
+ foreach ($child in $Group.Children) {
+ if ($child.Type -eq '/subscriptions') {
+ $Map.Value[$child.Name] = $Group.DisplayName
+ }
+ elseif ($child.Children) {
+ Build-SubMap -Group $child -Map $Map
+ }
+ }
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1
new file mode 100644
index 000000000..8bbad1183
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1
@@ -0,0 +1,256 @@
+###########################################################################
+# INITIALIZE-SCANNER.PS1
+# AZURE FINOPS MULTITOOL - Authentication & Prerequisites
+###########################################################################
+# Purpose: Validate required Az modules, authenticate to Azure, and return
+# tenant context for the scanner to operate against.
+###########################################################################
+
+function Show-TenantPicker {
+ param([object[]]$Tenants)
+
+ Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue
+
+ $pickerXaml = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+
+ $rdr = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($pickerXaml))
+ $dlg = [System.Windows.Markup.XamlReader]::Load($rdr)
+
+ $list = $dlg.FindName('TenantList')
+ $okBtn = $dlg.FindName('OkBtn')
+ $cancelBtn = $dlg.FindName('CancelBtn')
+
+ foreach ($t in $Tenants) {
+ $envTag = if ($t.PSObject.Properties['Environment']) { $t.Environment } else { '' }
+ $envLabel = switch ($envTag) {
+ 'AzureUSGovernment' { ' [GOV]' }
+ 'AzureCloud' { ' [Commercial]' }
+ default { '' }
+ }
+ $display = if ($t.Name -and $t.Name -ne $t.TenantId) { "$($t.Name)$envLabel ($($t.TenantId))" } else { "$($t.TenantId)$envLabel" }
+ $item = [System.Windows.Controls.ListBoxItem]::new()
+ $item.Content = $display
+ $item.Tag = "$($t.TenantId)|$envTag"
+ $list.Items.Add($item) | Out-Null
+ }
+
+ $list.Add_SelectionChanged({ $okBtn.IsEnabled = ($list.SelectedItem -ne $null) })
+ $list.Add_MouseDoubleClick({ if ($list.SelectedItem) { $dlg.DialogResult = $true; $dlg.Close() } })
+ $okBtn.Add_Click({ $dlg.DialogResult = $true; $dlg.Close() })
+ $cancelBtn.Add_Click({ $dlg.DialogResult = $false; $dlg.Close() })
+
+ if ($list.Items.Count -gt 0) { $list.SelectedIndex = 0 }
+
+ $picked = $dlg.ShowDialog()
+ if ($picked -and $list.SelectedItem) {
+ $parts = $list.SelectedItem.Tag -split '\|', 2
+ return [PSCustomObject]@{ TenantId = $parts[0]; Environment = $parts[1] }
+ }
+ return $null
+}
+
+function Initialize-Scanner {
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ [ValidateSet('AzureCloud', 'AzureUSGovernment', 'AzureChinaCloud', 'AzureGermanCloud', '')]
+ [string]$Environment = '',
+
+ [Parameter()]
+ [System.Windows.Window]$ParentWindow,
+
+ [Parameter()]
+ [switch]$IncludeAlternateCloud
+ )
+
+ $requiredModules = @('Az.Accounts', 'Az.Resources', 'Az.ResourceGraph', 'Az.CostManagement', 'Az.Advisor', 'Az.Billing')
+ $missing = @()
+
+ foreach ($mod in $requiredModules) {
+ if (-not (Get-Module -ListAvailable -Name $mod)) {
+ $missing += $mod
+ }
+ }
+
+ if ($missing.Count -gt 0) {
+ throw "Missing required modules: $($missing -join ', '). Run: Install-Module $($missing -join ', ') -Scope CurrentUser"
+ }
+
+ # Check for existing session and auto-detect environment
+ $ctx = Get-AzContext -ErrorAction SilentlyContinue
+
+ # If caller specified an environment, use it; otherwise detect from session
+ if (-not $Environment) {
+ if ($ctx) {
+ $Environment = $ctx.Environment.Name
+ Write-Host " Detected Azure environment: $Environment" -ForegroundColor Cyan
+ } else {
+ $Environment = 'AzureCloud'
+ }
+ }
+
+ # Disable the new Az login experience subscription picker (Az.Accounts 12+)
+ # so Connect-AzAccount goes straight through without console prompts
+ $env:AZURE_LOGIN_EXPERIENCE_V2 = 'Off'
+
+ # Reuse existing session if one exists in the target cloud; otherwise prompt login
+ if ($ctx -and $ctx.Account -and $ctx.Environment.Name -eq $Environment) {
+ Write-Host " Using existing Azure session: $($ctx.Account.Id) ($Environment)" -ForegroundColor Cyan
+ } else {
+ Write-Host " Authenticating to Azure ($Environment)..." -ForegroundColor Cyan
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Minimized' }
+ try {
+ Connect-AzAccount -Environment $Environment -ErrorAction Stop | Out-Null
+ } finally {
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Normal'; $ParentWindow.Activate() }
+ }
+ $ctx = Get-AzContext
+ }
+
+ # List all accessible tenants across environments
+ Write-Host " Loading accessible tenants..." -ForegroundColor Cyan
+ $allTenants = [System.Collections.Generic.List[object]]::new()
+ $seenTenantIds = @{}
+
+ # Get tenants from current environment
+ $tenants = @(Get-AzTenant -ErrorAction SilentlyContinue)
+ foreach ($t in $tenants) {
+ $t | Add-Member -NotePropertyName 'Environment' -NotePropertyValue $Environment -Force
+ $allTenants.Add($t)
+ $seenTenantIds[$t.TenantId] = $true
+ }
+
+ # Probe the alternate environment for additional tenants (opt-in only)
+ if ($IncludeAlternateCloud) {
+ $altEnv = if ($Environment -eq 'AzureCloud') { 'AzureUSGovernment' } else { 'AzureCloud' }
+ try {
+ Write-Host " Checking $altEnv for additional tenants..." -ForegroundColor Cyan
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Minimized' }
+ Connect-AzAccount -Environment $altEnv -ErrorAction Stop | Out-Null
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Normal'; $ParentWindow.Activate() }
+ $altTenants = @(Get-AzTenant -ErrorAction SilentlyContinue)
+ foreach ($t in $altTenants) {
+ if (-not $seenTenantIds.ContainsKey($t.TenantId)) {
+ $t | Add-Member -NotePropertyName 'Environment' -NotePropertyValue $altEnv -Force
+ $allTenants.Add($t)
+ $seenTenantIds[$t.TenantId] = $true
+ }
+ }
+ # Switch back to original environment context
+ Connect-AzAccount -Environment $Environment -ErrorAction SilentlyContinue | Out-Null
+ } catch {
+ Write-Host " No additional tenants found in $altEnv" -ForegroundColor DarkGray
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Normal'; $ParentWindow.Activate() }
+ }
+ }
+
+ if ($allTenants.Count -eq 0) {
+ throw "No accessible tenants found."
+ }
+
+ # Always show tenant picker (even with 1 tenant, let user confirm)
+ $selection = Show-TenantPicker -Tenants $allTenants
+ if (-not $selection) {
+ throw "Tenant selection cancelled."
+ }
+
+ $selectedTenantId = $selection.TenantId
+ $selectedEnv = if ($selection.Environment) { $selection.Environment } else { $Environment }
+
+ # Switch to the selected tenant - use Set-AzContext if same cloud, full Connect if different
+ if ($selectedTenantId -ne $ctx.Tenant.Id -or $selectedEnv -ne $ctx.Environment.Name) {
+ Write-Host " Switching to tenant $selectedTenantId ($selectedEnv)..." -ForegroundColor Cyan
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Minimized' }
+ try {
+ Connect-AzAccount -Environment $selectedEnv -TenantId $selectedTenantId -ErrorAction Stop | Out-Null
+ } finally {
+ if ($ParentWindow) { $ParentWindow.WindowState = 'Normal'; $ParentWindow.Activate() }
+ }
+ } else {
+ # Same tenant selected - explicitly set context to be safe
+ Write-Host " Confirming context for tenant $selectedTenantId..." -ForegroundColor Cyan
+ Set-AzContext -TenantId $selectedTenantId -ErrorAction SilentlyContinue | Out-Null
+ }
+ $ctx = Get-AzContext
+
+ # Verify the context actually landed on the right tenant
+ if ($ctx.Tenant.Id -ne $selectedTenantId) {
+ throw "Context mismatch: expected tenant $selectedTenantId but got $($ctx.Tenant.Id). Try closing all PowerShell sessions and re-running."
+ }
+
+ $tenantId = $ctx.Tenant.Id
+ $accountName = $ctx.Account.Id
+
+ # Get all accessible subscriptions
+ $subscriptions = @(Get-AzSubscription -TenantId $tenantId -ErrorAction SilentlyContinue |
+ Where-Object { $_.State -eq 'Enabled' })
+
+ # Categorize subscriptions: separate VS/MSDN/DevTest/Free subs
+ # These have spending limits, often fail Cost Management APIs, and
+ # looping through hundreds of them in a large tenant wastes hours.
+ $prodSubs = [System.Collections.Generic.List[object]]::new()
+ $skippedSubs = [System.Collections.Generic.List[object]]::new()
+
+ $skipPatterns = @(
+ 'Visual Studio', 'MSDN',
+ 'Free Trial', 'Sponsorship', 'Access to Azure Active Directory',
+ 'Azure Pass', 'BizSpark', 'Imagine', 'MPN', 'Azure in Open'
+ )
+ $skipRegex = ($skipPatterns | ForEach-Object { [regex]::Escape($_) }) -join '|'
+
+ foreach ($sub in $subscriptions) {
+ if ($sub.Name -match $skipRegex) {
+ [void]$skippedSubs.Add($sub)
+ } else {
+ [void]$prodSubs.Add($sub)
+ }
+ }
+
+ if ($skippedSubs.Count -gt 0) {
+ Write-Host " Subscriptions: $($prodSubs.Count) production, $($skippedSubs.Count) skipped (VS/MSDN/DevTest/Free)" -ForegroundColor Yellow
+ }
+
+ # Classify tenant size for adaptive scan strategies
+ $tenantSize = if ($prodSubs.Count -le 10) { 'Small' }
+ elseif ($prodSubs.Count -le 50) { 'Medium' }
+ else { 'Large' }
+ $sizeNote = switch ($tenantSize) {
+ 'Small' { "fast scan mode" }
+ 'Medium' { "standard scan mode" }
+ 'Large' { "optimized scan mode (sampling + Resource Graph)" }
+ }
+ Write-Host " Tenant size: $tenantSize ($($prodSubs.Count) subs) - $sizeNote" -ForegroundColor Cyan
+
+ return [PSCustomObject]@{
+ TenantId = $tenantId
+ AccountName = $accountName
+ Subscriptions = @($prodSubs)
+ AllSubscriptions = $subscriptions
+ SkippedSubs = @($skippedSubs)
+ Environment = $ctx.Environment.Name
+ TenantSize = $tenantSize
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1
new file mode 100644
index 000000000..6df8e529b
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1
@@ -0,0 +1,13 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+function Get-PlainAccessToken {
+ param([string]$ResourceUrl = 'https://management.azure.com')
+ $tok = (Get-AzAccessToken -ResourceUrl $ResourceUrl).Token
+ if ($tok -is [securestring]) {
+ $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tok)
+ try { [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) }
+ finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
+ }
+ else { $tok }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1
new file mode 100644
index 000000000..750875a51
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1
@@ -0,0 +1,147 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+# -- Shared Runspace Pool --------------------------------------------------
+# Created once at module load. Reused by Invoke-AzRestMethodWithRetry and
+# Search-AzGraphSafe to avoid the ~1-2s cold-start per runspace creation.
+if (-not $script:RunspacePool -or $script:RunspacePool.RunspacePoolStateInfo.State -ne 'Opened') {
+ $script:RunspacePool = [runspacefactory]::CreateRunspacePool(1, 6)
+ $script:RunspacePool.Open()
+}
+
+# -- WPF Detection ---------------------------------------------------------
+# When running standalone (no GUI), skip DispatcherFrame pumping and use
+# simple Start-Sleep instead. This lets the same code work in both contexts.
+function Test-WpfLoaded {
+ try {
+ $dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher
+ return ($null -ne $dispatcher -and
+ -not $dispatcher.HasShutdownStarted -and
+ [System.Windows.Application]::Current -ne $null)
+ }
+ catch { return $false }
+}
+
+function Wait-WithDispatcher {
+ param([int]$Milliseconds)
+ if (Test-WpfLoaded) {
+ $waitEnd = (Get-Date).AddMilliseconds($Milliseconds)
+ while ((Get-Date) -lt $waitEnd) {
+ $frame = [System.Windows.Threading.DispatcherFrame]::new()
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
+ [System.Windows.Threading.DispatcherPriority]::Background,
+ [action] { $frame.Continue = $false }
+ )
+ [System.Windows.Threading.Dispatcher]::PushFrame($frame)
+ Start-Sleep -Milliseconds 100
+ }
+ }
+ else {
+ Start-Sleep -Milliseconds $Milliseconds
+ }
+}
+
+function Wait-ForRunspace {
+ param(
+ [System.IAsyncResult]$AsyncResult,
+ [int]$TimeoutSeconds = 60
+ )
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ if (Test-WpfLoaded) {
+ while (-not $AsyncResult.IsCompleted -and (Get-Date) -lt $deadline) {
+ $frame = [System.Windows.Threading.DispatcherFrame]::new()
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke(
+ [System.Windows.Threading.DispatcherPriority]::Background,
+ [action] { $frame.Continue = $false }
+ )
+ [System.Windows.Threading.Dispatcher]::PushFrame($frame)
+ Start-Sleep -Milliseconds 100
+ }
+ }
+ else {
+ while (-not $AsyncResult.IsCompleted -and (Get-Date) -lt $deadline) {
+ Start-Sleep -Milliseconds 200
+ }
+ }
+}
+
+function Invoke-AzRestMethodWithRetry {
+ param(
+ [string]$Path,
+ [string]$Method = 'POST',
+ [string]$Payload,
+ [int]$MaxRetries = 3,
+ [int]$TimeoutSeconds = 60
+ )
+ for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) {
+ $ps = [powershell]::Create()
+ $ps.RunspacePool = $script:RunspacePool
+ [void]$ps.AddScript({
+ param($p, $m, $pl)
+ $params = @{ Path = $p; Method = $m; ErrorAction = 'Stop' }
+ if ($pl) { $params['Payload'] = $pl }
+ $r = Invoke-AzRestMethod @params
+ $hdrs = @{}
+ if ($r.Headers) {
+ foreach ($k in $r.Headers.Keys) { $hdrs[$k] = $r.Headers[$k] }
+ }
+ [PSCustomObject]@{
+ StatusCode = $r.StatusCode
+ Content = $r.Content
+ Headers = $hdrs
+ }
+ }).AddArgument($Path).AddArgument($Method).AddArgument($Payload)
+
+ $asyncResult = $ps.BeginInvoke()
+ Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds
+
+ $resp = $null
+ if ($asyncResult.IsCompleted) {
+ try {
+ $raw = $ps.EndInvoke($asyncResult)
+ $resp = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null }
+ }
+ catch {
+ $ps.Dispose()
+ throw
+ }
+ }
+ else {
+ $ps.Stop()
+ Write-Warning " REST call timed out after $($TimeoutSeconds)s: $Method $Path"
+ $ps.Dispose()
+ return [PSCustomObject]@{ StatusCode = 408; Content = '{"error":{"message":"Request timed out"}}'; Headers = @{} }
+ }
+
+ $ps.Dispose()
+
+ if (-not $resp) {
+ $resp = [PSCustomObject]@{ StatusCode = 0; Content = $null; Headers = @{} }
+ }
+ if ($null -eq $resp.Content) {
+ $resp = [PSCustomObject]@{ StatusCode = $resp.StatusCode; Content = '{}'; Headers = if ($resp.Headers) { $resp.Headers } else { @{} } }
+ }
+
+ if ($resp.StatusCode -ne 429) { return $resp }
+
+ # Parse Retry-After header or default to exponential backoff
+ $retryAfter = 10
+ if ($resp.Headers -and $resp.Headers['Retry-After']) {
+ $parsed = 0
+ if ([int]::TryParse($resp.Headers['Retry-After'], [ref]$parsed)) {
+ $retryAfter = [math]::Max($parsed, 5)
+ }
+ }
+ else {
+ $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 60)
+ }
+ Write-Host " [429 Throttled] Waiting $($retryAfter)s before retry ($($attempt+1)/$MaxRetries)..." -ForegroundColor Yellow
+
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Rate limited - waiting $($retryAfter)s before retry ($($attempt+1)/$MaxRetries)..."
+ }
+
+ Wait-WithDispatcher -Milliseconds ($retryAfter * 1000)
+ }
+ return $resp
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1
new file mode 100644
index 000000000..078839758
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1
@@ -0,0 +1,16 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+# -- MG-Scope State --------------------------------------------------------
+# First cost module that gets 401/403 at MG scope sets this to $true.
+# All subsequent modules check it and skip to per-sub immediately.
+$script:MgCostScopeFailed = $false
+
+function Test-MgCostScope {
+ return (-not $script:MgCostScopeFailed)
+}
+
+function Set-MgCostScopeFailed {
+ $script:MgCostScopeFailed = $true
+ Write-Host " MG-scope cost access unavailable for this tenant - all subsequent modules will use per-subscription queries" -ForegroundColor Yellow
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1
new file mode 100644
index 000000000..b3eb3cc7e
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1
@@ -0,0 +1,609 @@
+###########################################################################
+# READ-FINOPSHUBDATA.PS1
+# FINOPS HUB STORAGE DATA READER
+###########################################################################
+# Purpose: Read cost data from a FinOps Hub storage account
+# Author: Zac Larsen
+# Date: Created for FinOps Multitool TUI integration
+#
+# Description:
+# Reads FOCUS-schema cost data from a Hub storage account.
+# Prefers parquet from the ingestion container (normalized FOCUS);
+# falls back to CSV from msexports if ingestion is empty.
+# Parquet.Net + all transitive deps are auto-installed via nuget.exe.
+#
+# 1. Checks ingestion container for parquet (preferred)
+# 2. Falls back to msexports CSV if no parquet found
+# 3. Returns FOCUS-schema cost objects for Multitool consumption
+#
+# ── Parameters ──────────────────────────────────────────────
+# StorageAccountName Hub storage account name
+# ResourceGroupName Resource group containing the storage account
+# Months Number of months to read (default: 1)
+#
+# Prerequisites:
+# - Az.Storage module
+# - Storage Blob Data Reader RBAC on the Hub storage account
+###########################################################################
+
+# Helper: Convert JSON to hashtable (PS 5.1 compatible — no -AsHashtable)
+function ConvertTo-HashtableFromJson {
+ param([string]$Json)
+ $obj = $Json | ConvertFrom-Json -ErrorAction Stop
+ $ht = @{}
+ foreach ($p in $obj.PSObject.Properties) { $ht[$p.Name] = $p.Value }
+ return $ht
+}
+
+function Install-ParquetReader {
+ [CmdletBinding()]
+ param()
+
+ # Check if Parquet is already loaded in this session
+ $loaded = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object {
+ $_.GetName().Name -eq 'Parquet'
+ }
+ if ($loaded) { return $true }
+
+ $parquetDir = Join-Path ([System.IO.Path]::GetTempPath()) 'FinOpsMultitool-Parquet'
+ $markerFile = Join-Path $parquetDir '.installed'
+
+ # If all DLLs were previously installed, just load them
+ if (Test-Path $markerFile) {
+ try {
+ Import-ParquetAssemblies -BasePath $parquetDir
+ return $true
+ }
+ catch {
+ # Corrupt install — wipe and redo
+ Remove-Item $parquetDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ Write-Host " Installing Parquet reader (one-time setup)..." -ForegroundColor DarkGray
+
+ try {
+ New-Item -ItemType Directory -Path $parquetDir -Force | Out-Null
+
+ # Download nuget.exe if needed
+ $nugetExe = Join-Path $parquetDir 'nuget.exe'
+ if (-not (Test-Path $nugetExe)) {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+ Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetExe -UseBasicParsing
+ }
+
+ # Use nuget.exe to resolve ALL transitive dependencies
+ $pkgDir = Join-Path $parquetDir 'packages'
+ & $nugetExe install Parquet.Net -Version 4.24.0 -OutputDirectory $pkgDir -Framework net8.0 2>&1 | Out-Null
+
+ # Copy managed DLLs to flat directory (prefer net8.0 > net6.0 > netstandard2.0)
+ $libDir = Join-Path $parquetDir 'lib'
+ New-Item -ItemType Directory -Path $libDir -Force | Out-Null
+
+ $fxPriority = @('net8.0', 'net6.0', 'netstandard2.1', 'netstandard2.0')
+ $packages = Get-ChildItem $pkgDir -Directory
+ foreach ($pkg in $packages) {
+ $libRoot = Join-Path $pkg.FullName 'lib'
+ if (-not (Test-Path $libRoot)) { continue }
+ $copied = $false
+ foreach ($fx in $fxPriority) {
+ $fxDir = Join-Path $libRoot $fx
+ if (Test-Path $fxDir) {
+ Get-ChildItem $fxDir -Filter '*.dll' | ForEach-Object {
+ Copy-Item $_.FullName $libDir -Force
+ }
+ $copied = $true
+ break
+ }
+ }
+ # Copy native runtimes (IronCompress needs nironcompress.dll)
+ $nativeDir = Join-Path $pkg.FullName 'runtimes\win-x64\native'
+ if (Test-Path $nativeDir) {
+ $targetNative = Join-Path $parquetDir 'runtimes\win-x64\native'
+ New-Item -ItemType Directory -Path $targetNative -Force | Out-Null
+ Get-ChildItem $nativeDir -Filter '*.dll' | ForEach-Object {
+ Copy-Item $_.FullName $targetNative -Force
+ }
+ }
+ }
+
+ # Write marker so next session skips the nuget step
+ 'installed' | Set-Content $markerFile
+
+ Import-ParquetAssemblies -BasePath $parquetDir
+ Write-Host " Parquet reader installed." -ForegroundColor DarkGray
+ return $true
+ }
+ catch {
+ Write-Warning "Failed to install Parquet reader: $($_.Exception.Message)"
+ return $false
+ }
+}
+
+function Import-ParquetAssemblies {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$BasePath
+ )
+
+ $libDir = Join-Path $BasePath 'lib'
+
+ # Load order matters — dependencies before dependents
+ $loadOrder = @(
+ 'System.Buffers.dll'
+ 'System.Memory.dll'
+ 'System.Runtime.CompilerServices.Unsafe.dll'
+ 'System.Collections.Immutable.dll'
+ 'Microsoft.IO.RecyclableMemoryStream.dll'
+ 'ZstdSharp.dll'
+ 'Snappier.dll'
+ 'IronCompress.dll'
+ 'Apache.Arrow.dll'
+ 'Microsoft.ML.DataView.dll'
+ 'Microsoft.Data.Analysis.dll'
+ 'Parquet.dll'
+ )
+
+ foreach ($dll in $loadOrder) {
+ $path = Join-Path $libDir $dll
+ if (-not (Test-Path $path)) { continue }
+
+ $asmName = [IO.Path]::GetFileNameWithoutExtension($dll)
+ $already = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object {
+ $_.GetName().Name -eq $asmName
+ }
+ if ($already) { continue }
+
+ try {
+ Add-Type -Path $path -ErrorAction Stop
+ }
+ catch {
+ # Swallow if the runtime already provides this assembly
+ $recheck = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object {
+ $_.GetName().Name -eq $asmName
+ }
+ if (-not $recheck) { throw }
+ }
+ }
+}
+
+function Read-ParquetFile {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$Path
+ )
+
+ try {
+ $table = [Parquet.ParquetReader]::ReadTableFromFileAsync($Path, $null).GetAwaiter().GetResult()
+ if (-not $table -or $table.Count -eq 0) { return @() }
+
+ $results = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $colNames = @($table.Schema.GetDataFields() | ForEach-Object { $_.Name })
+
+ for ($i = 0; $i -lt $table.Count; $i++) {
+ $obj = [ordered]@{}
+ foreach ($colName in $colNames) {
+ $col = $table[$colName]
+ $obj[$colName] = if ($col -and $i -lt $col.Data.Length) { $col.Data[$i] } else { $null }
+ }
+ $results.Add([PSCustomObject]$obj)
+ }
+
+ return $results
+ }
+ catch {
+ Write-Warning "Failed to read parquet file $Path`: $($_.Exception.Message)"
+ return @()
+ }
+}
+
+function Read-FinOpsHubData {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$StorageAccountName,
+
+ [Parameter(Mandatory)]
+ [string]$ResourceGroupName,
+
+ [Parameter()]
+ [int]$Months = 1
+ )
+
+ Write-Host " Connecting to Hub storage: $StorageAccountName" -ForegroundColor DarkGray
+
+ try {
+ $ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount -ErrorAction Stop
+ }
+ catch {
+ Write-Host " Failed to connect to Hub storage: $($_.Exception.Message)" -ForegroundColor Yellow
+ return $null
+ }
+
+ $allData = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "FinOpsHub-$([guid]::NewGuid().ToString('N').Substring(0,8))"
+ New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
+
+ try {
+ # -- Strategy 1: Parquet from ingestion (normalized FOCUS) ---------
+ $now = Get-Date
+ for ($m = 0; $m -lt $Months; $m++) {
+ $d = $now.AddMonths(-$m)
+ $basePath = "Costs/$($d.ToString('yyyy'))/$($d.ToString('MM'))"
+ try {
+ $blobs = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'ingestion' -Path $basePath -Recurse -ErrorAction Stop |
+ Where-Object { -not $_.IsDirectory -and $_.Name -like '*.parquet' })
+
+ if ($blobs.Count -gt 0) {
+ # Install parquet reader on first parquet file encountered
+ if ($allData.Count -eq 0) {
+ $hasParquet = Install-ParquetReader
+ if (-not $hasParquet) {
+ Write-Warning "Parquet reader failed — falling back to CSV exports"
+ break
+ }
+ }
+
+ Write-Host " Reading ingestion: $basePath ($($blobs.Count) file(s))" -ForegroundColor DarkGray
+ foreach ($blob in $blobs) {
+ $localFile = Join-Path $tempDir "$([guid]::NewGuid().ToString('N')).parquet"
+ try {
+ Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'ingestion' -Path $blob.Path -Destination $localFile -Force -ErrorAction Stop | Out-Null
+ $rows = Read-ParquetFile -Path $localFile
+ if ($rows -and @($rows).Count -gt 0) {
+ foreach ($row in $rows) { $allData.Add($row) }
+ Write-Host " Loaded $(@($rows).Count) rows from $(Split-Path $blob.Path -Leaf)" -ForegroundColor DarkGray
+ }
+ }
+ finally {
+ Remove-Item $localFile -Force -ErrorAction SilentlyContinue
+ }
+ }
+ }
+ }
+ catch {
+ # Path doesn't exist yet — that's OK
+ }
+ }
+
+ # -- Strategy 2: CSV from msexports (raw FOCUS export) -------------
+ if ($allData.Count -eq 0) {
+ Write-Host " No parquet in ingestion — reading CSV from msexports..." -ForegroundColor DarkGray
+
+ $csvBlobs = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'msexports' -Recurse -ErrorAction SilentlyContinue |
+ Where-Object { -not $_.IsDirectory -and $_.Path -like '*.csv' })
+
+ if ($csvBlobs.Count -gt 0) {
+ # Sort descending to get newest export run first
+ $csvBlobs = $csvBlobs | Sort-Object Path -Descending
+
+ # Group by export run folder (parent of the CSV)
+ $runs = [ordered]@{}
+ foreach ($blob in $csvBlobs) {
+ $runFolder = Split-Path $blob.Path -Parent
+ if (-not $runs.Contains($runFolder)) {
+ $runs[$runFolder] = [System.Collections.Generic.List[object]]::new()
+ }
+ $runs[$runFolder].Add($blob)
+ }
+
+ # Take the most recent run
+ $latestRun = ($runs.GetEnumerator() | Select-Object -First 1).Value
+ Write-Host " Found $($latestRun.Count) CSV file(s) from latest export" -ForegroundColor DarkGray
+
+ foreach ($blob in $latestRun) {
+ $localFile = Join-Path $tempDir "$(Split-Path $blob.Path -Leaf)"
+ try {
+ Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'msexports' -Path $blob.Path -Destination $localFile -Force -ErrorAction Stop | Out-Null
+ $rows = Import-Csv -Path $localFile
+ if ($rows -and @($rows).Count -gt 0) {
+ foreach ($row in $rows) { $allData.Add($row) }
+ Write-Host " Loaded $(@($rows).Count) rows from CSV" -ForegroundColor DarkGray
+ }
+ }
+ catch {
+ Write-Warning "Failed to read CSV: $($_.Exception.Message)"
+ }
+ finally {
+ Remove-Item $localFile -Force -ErrorAction SilentlyContinue
+ }
+ }
+ }
+ else {
+ Write-Host " No cost data found in Hub storage" -ForegroundColor Yellow
+ }
+ }
+ }
+ finally {
+ Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
+ if ($allData.Count -gt 0) {
+ $source = if ($allData[0].PSObject.Properties.Name -contains 'x_SkuTier') { 'CSV' } else { 'parquet' }
+ Write-Host " Total rows from Hub ($source): $($allData.Count)" -ForegroundColor Green
+ }
+
+ return $allData
+}
+
+function ConvertTo-CostDataFromHub {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$HubData
+ )
+
+ # Convert FOCUS-schema Hub data into the same hashtable format
+ # that Get-CostData returns: @{ subscriptionId = @{ Actual; Forecast; Currency } }
+ $costMap = @{}
+ $props = $HubData[0].PSObject.Properties.Name
+
+ foreach ($row in $HubData) {
+ $subId = if ($props -contains 'SubAccountId' -and $row.SubAccountId) { $row.SubAccountId }
+ elseif ($props -contains 'SubscriptionId' -and $row.SubscriptionId) { $row.SubscriptionId }
+ elseif ($props -contains 'x_SubscriptionId' -and $row.x_SubscriptionId) { $row.x_SubscriptionId }
+ else { 'unknown' }
+
+ # FOCUS SubAccountId may be full resource path — extract just the GUID
+ if ($subId -match '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') {
+ $subId = $Matches[0]
+ }
+
+ $cost = if ($props -contains 'CostInBillingCurrency' -and $row.CostInBillingCurrency) { [double]$row.CostInBillingCurrency }
+ elseif ($props -contains 'BilledCost' -and $row.BilledCost) { [double]$row.BilledCost }
+ elseif ($props -contains 'EffectiveCost' -and $row.EffectiveCost) { [double]$row.EffectiveCost }
+ else { 0 }
+
+ $currency = if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $row.BillingCurrency }
+ elseif ($props -contains 'BillingCurrencyCode' -and $row.BillingCurrencyCode) { $row.BillingCurrencyCode }
+ else { 'USD' }
+
+ if (-not $costMap.ContainsKey($subId)) {
+ $costMap[$subId] = @{ Actual = 0.0; Forecast = 0.0; Currency = $currency }
+ }
+ $costMap[$subId].Actual += $cost
+ }
+
+ return $costMap
+}
+
+function ConvertTo-ResourceCostsFromHub {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$HubData
+ )
+
+ # Aggregate by resource and return in the same format as Get-ResourceCosts
+ $resourceMap = @{}
+ $props = $HubData[0].PSObject.Properties.Name
+
+ foreach ($row in $HubData) {
+ $subName = if ($props -contains 'SubAccountName' -and $row.SubAccountName) { $row.SubAccountName }
+ elseif ($props -contains 'SubscriptionName' -and $row.SubscriptionName) { $row.SubscriptionName }
+ elseif ($props -contains 'SubAccountId') { $row.SubAccountId }
+ else { 'unknown' }
+
+ $rg = if ($props -contains 'x_ResourceGroupName' -and $row.x_ResourceGroupName) { $row.x_ResourceGroupName }
+ elseif ($props -contains 'ResourceGroup' -and $row.ResourceGroup) { $row.ResourceGroup }
+ elseif ($props -contains 'ResourceGroupName' -and $row.ResourceGroupName) { $row.ResourceGroupName }
+ else { 'unknown' }
+
+ $resType = if ($props -contains 'ResourceType' -and $row.ResourceType) { $row.ResourceType }
+ elseif ($props -contains 'x_ResourceType' -and $row.x_ResourceType) { $row.x_ResourceType }
+ elseif ($props -contains 'ConsumedService' -and $row.ConsumedService) { $row.ConsumedService }
+ else { 'unknown' }
+
+ $resId = if ($props -contains 'ResourceId' -and $row.ResourceId) { $row.ResourceId }
+ elseif ($props -contains 'x_ResourceId' -and $row.x_ResourceId) { $row.x_ResourceId }
+ else { "$rg/$resType" }
+
+ $cost = if ($props -contains 'CostInBillingCurrency' -and $row.CostInBillingCurrency) { [double]$row.CostInBillingCurrency }
+ elseif ($props -contains 'BilledCost' -and $row.BilledCost) { [double]$row.BilledCost }
+ elseif ($props -contains 'EffectiveCost' -and $row.EffectiveCost) { [double]$row.EffectiveCost }
+ else { 0 }
+
+ $currency = if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $row.BillingCurrency }
+ elseif ($props -contains 'BillingCurrencyCode' -and $row.BillingCurrencyCode) { $row.BillingCurrencyCode }
+ else { 'USD' }
+
+ $key = $resId
+ if (-not $resourceMap.ContainsKey($key)) {
+ $resourceMap[$key] = [PSCustomObject]@{
+ Subscription = $subName
+ ResourceGroup = $rg
+ ResourceType = $resType
+ ResourcePath = $resId
+ Actual = 0.0
+ Forecast = 0.0
+ Currency = $currency
+ }
+ }
+ $resourceMap[$key].Actual += $cost
+ }
+
+ return @($resourceMap.Values | Sort-Object { $_.Actual } -Descending)
+}
+
+function ConvertTo-TagInventoryFromHub {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$HubData
+ )
+
+ # Extract tag inventory from FOCUS cost data Tags JSON column
+ # Returns same structure as Get-TagInventory
+ $tagNames = @{}
+ $totalResources = 0
+ $taggedCount = 0
+ $untaggedResources = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $seenResources = @{}
+ $props = $HubData[0].PSObject.Properties.Name
+
+ foreach ($row in $HubData) {
+ $resId = if ($props -contains 'ResourceId' -and $row.ResourceId) { $row.ResourceId }
+ elseif ($props -contains 'x_ResourceId' -and $row.x_ResourceId) { $row.x_ResourceId }
+ else { $null }
+
+ # Deduplicate by resource ID (cost rows repeat per line item)
+ if (-not $resId -or $seenResources.ContainsKey($resId)) { continue }
+ $seenResources[$resId] = $true
+ $totalResources++
+
+ $resName = if ($props -contains 'ResourceName' -and $row.ResourceName) { $row.ResourceName } else { Split-Path $resId -Leaf }
+ $resType = if ($props -contains 'ResourceType' -and $row.ResourceType) { $row.ResourceType }
+ elseif ($props -contains 'x_ResourceType' -and $row.x_ResourceType) { $row.x_ResourceType }
+ else { 'unknown' }
+ $rg = if ($props -contains 'x_ResourceGroupName' -and $row.x_ResourceGroupName) { $row.x_ResourceGroupName }
+ elseif ($props -contains 'ResourceGroup' -and $row.ResourceGroup) { $row.ResourceGroup }
+ else { 'unknown' }
+ $sub = if ($props -contains 'SubAccountName' -and $row.SubAccountName) { $row.SubAccountName }
+ elseif ($props -contains 'SubscriptionName' -and $row.SubscriptionName) { $row.SubscriptionName }
+ else { 'unknown' }
+
+ # Parse Tags JSON
+ $tagsJson = if ($props -contains 'Tags') { $row.Tags } else { $null }
+ $tagDict = $null
+ if ($tagsJson -and $tagsJson.Trim() -ne '' -and $tagsJson.Trim() -ne '{}') {
+ try { $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson } catch { }
+ }
+
+ if ($tagDict -and $tagDict.Count -gt 0) {
+ $taggedCount++
+ foreach ($kv in $tagDict.GetEnumerator()) {
+ $tName = $kv.Key
+ $tVal = if ($kv.Value) { "$($kv.Value)" } else { '(empty)' }
+
+ if (-not $tagNames.ContainsKey($tName)) {
+ $tagNames[$tName] = @{
+ Values = @{}
+ TotalResources = 0
+ }
+ }
+ $tagNames[$tName].TotalResources++
+
+ if (-not $tagNames[$tName].Values.ContainsKey($tVal)) {
+ $tagNames[$tName].Values[$tVal] = @{ ResourceCount = 0; ResourceTypes = @{} }
+ }
+ $tagNames[$tName].Values[$tVal].ResourceCount++
+ $tagNames[$tName].Values[$tVal].ResourceTypes[$resType] = $true
+ }
+ }
+ else {
+ if ($untaggedResources.Count -lt 500) {
+ $untaggedResources.Add([PSCustomObject]@{
+ ResourceName = $resName
+ ResourceType = $resType
+ ResourceGroup = $rg
+ Subscription = $sub
+ Location = ''
+ })
+ }
+ }
+ }
+
+ # Convert Values hashes to arrays matching Get-TagInventory format
+ $tagNamesOut = @{}
+ foreach ($kv in $tagNames.GetEnumerator()) {
+ $valArray = @()
+ foreach ($v in $kv.Value.Values.GetEnumerator()) {
+ $valArray += [PSCustomObject]@{
+ TagValue = $v.Key
+ ResourceCount = $v.Value.ResourceCount
+ ResourceTypes = @($v.Value.ResourceTypes.Keys)
+ }
+ }
+ $tagNamesOut[$kv.Key] = @{
+ Values = ($valArray | Sort-Object ResourceCount -Descending)
+ TotalResources = $kv.Value.TotalResources
+ }
+ }
+
+ $untaggedCount = $totalResources - $taggedCount
+ $coverage = if ($totalResources -gt 0) { [math]::Round(($taggedCount / $totalResources) * 100, 1) } else { 0 }
+
+ return [PSCustomObject]@{
+ TagNames = $tagNamesOut
+ TagCount = $tagNamesOut.Count
+ TotalResources = $totalResources
+ TaggedCount = $taggedCount
+ UntaggedCount = $untaggedCount
+ TagCoverage = $coverage
+ UntaggedResources = @($untaggedResources)
+ Source = 'Hub'
+ }
+}
+
+function ConvertTo-CostByTagFromHub {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [object[]]$HubData,
+
+ [Parameter()]
+ [hashtable]$ExistingTags
+ )
+
+ # Aggregate cost by tag key/value from FOCUS cost data
+ # Returns same structure as Get-CostByTag
+ $props = $HubData[0].PSObject.Properties.Name
+ $costByTag = @{}
+ $currency = 'USD'
+
+ # Determine which tags to report on
+ $targetTags = if ($ExistingTags -and $ExistingTags.Count -gt 0) {
+ @($ExistingTags.Keys)
+ }
+ else { @() }
+
+ foreach ($row in $HubData) {
+ $cost = if ($props -contains 'CostInBillingCurrency' -and $row.CostInBillingCurrency) { [double]$row.CostInBillingCurrency }
+ elseif ($props -contains 'BilledCost' -and $row.BilledCost) { [double]$row.BilledCost }
+ elseif ($props -contains 'EffectiveCost' -and $row.EffectiveCost) { [double]$row.EffectiveCost }
+ else { 0 }
+
+ if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $currency = $row.BillingCurrency }
+
+ $tagsJson = if ($props -contains 'Tags') { $row.Tags } else { $null }
+ $tagDict = $null
+ if ($tagsJson -and $tagsJson.Trim() -ne '' -and $tagsJson.Trim() -ne '{}') {
+ try { $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson } catch { }
+ }
+
+ if ($targetTags.Count -eq 0 -and $tagDict -and $tagDict.Count -gt 0) {
+ $targetTags = @($tagDict.Keys)
+ }
+
+ foreach ($tagKey in $targetTags) {
+ if (-not $costByTag.ContainsKey($tagKey)) { $costByTag[$tagKey] = @{} }
+ $tagVal = if ($tagDict -and $tagDict.ContainsKey($tagKey)) { "$($tagDict[$tagKey])" } else { '(untagged)' }
+ if (-not $tagVal -or $tagVal -eq '') { $tagVal = '(empty)' }
+
+ if (-not $costByTag[$tagKey].ContainsKey($tagVal)) { $costByTag[$tagKey][$tagVal] = 0.0 }
+ $costByTag[$tagKey][$tagVal] += $cost
+ }
+ }
+
+ # Convert to output format matching Get-CostByTag
+ $costByTagOut = @{}
+ foreach ($kv in $costByTag.GetEnumerator()) {
+ $costByTagOut[$kv.Key] = @($kv.Value.GetEnumerator() | ForEach-Object {
+ [PSCustomObject]@{
+ TagValue = $_.Key
+ Cost = [math]::Round($_.Value, 2)
+ Currency = $currency
+ }
+ } | Sort-Object Cost -Descending)
+ }
+
+ return [PSCustomObject]@{
+ TagsQueried = @($costByTagOut.Keys)
+ CostByTag = $costByTagOut
+ NoTagsFound = ($costByTagOut.Count -eq 0)
+ UsedTimeframe = 'Hub export period'
+ Source = 'Hub'
+ }
+}
diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1
new file mode 100644
index 000000000..2a028769e
--- /dev/null
+++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1
@@ -0,0 +1,82 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+function Search-AzGraphSafe {
+ param(
+ [Parameter(Mandatory)][string]$Query,
+ [string[]]$Subscription,
+ [int]$First = 1000,
+ [string]$SkipToken,
+ [int]$TimeoutSeconds = 60,
+ [int]$MaxRetries = 2
+ )
+ for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) {
+ $ps = [powershell]::Create()
+ $ps.RunspacePool = $script:RunspacePool
+ [void]$ps.AddScript({
+ param($q, $s, $f, $st)
+ $p = @{ Query = $q; Subscription = $s; First = $f; ErrorAction = 'Stop' }
+ if ($st) { $p['SkipToken'] = $st }
+ $r = Search-AzGraph @p
+ $json = if ($r.Data -and $r.Data.Count -gt 0) {
+ $r.Data | ConvertTo-Json -Depth 20 -Compress
+ }
+ else { '[]' }
+ [PSCustomObject]@{
+ JsonData = $json
+ SkipToken = $r.SkipToken
+ Count = if ($r.Data) { $r.Data.Count } else { 0 }
+ }
+ }).AddArgument($Query).AddArgument($Subscription).AddArgument($First).AddArgument($SkipToken)
+
+ $asyncResult = $ps.BeginInvoke()
+ Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds
+
+ $result = $null
+ $is429 = $false
+ if ($asyncResult.IsCompleted) {
+ try {
+ $raw = $ps.EndInvoke($asyncResult)
+ $wrapper = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null }
+ if ($wrapper) {
+ $data = if ($wrapper.JsonData -and $wrapper.JsonData -ne '[]') {
+ $parsed = $wrapper.JsonData | ConvertFrom-Json
+ if ($parsed -is [array]) { $parsed } else { @($parsed) }
+ }
+ else { @() }
+ $result = [PSCustomObject]@{
+ Data = $data
+ SkipToken = $wrapper.SkipToken
+ Count = $wrapper.Count
+ }
+ }
+ if ($ps.Streams.Error.Count -gt 0) {
+ $errMsg = $ps.Streams.Error[0].Exception.Message
+ if ($errMsg -match '429|throttl|Too Many Requests') { $is429 = $true; $result = $null }
+ elseif (-not $result) { throw $ps.Streams.Error[0].Exception }
+ }
+ }
+ catch {
+ if ($_.Exception.Message -match '429|throttl|Too Many Requests') { $is429 = $true }
+ else { $ps.Dispose(); throw }
+ }
+ }
+ else {
+ $ps.Stop()
+ Write-Warning " Resource Graph query timed out after $($TimeoutSeconds)s"
+ }
+
+ $ps.Dispose()
+
+ if (-not $is429) { return $result }
+
+ # 429 retry wait
+ $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 30)
+ Write-Host " [429 Throttled - Resource Graph] Waiting $($retryAfter)s before retry ($($attempt+1)/$MaxRetries)..." -ForegroundColor Yellow
+ if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) {
+ Update-ScanStatus "Resource Graph rate limited - waiting $($retryAfter)s..."
+ }
+ Wait-WithDispatcher -Milliseconds ($retryAfter * 1000)
+ }
+ return $null
+}
diff --git a/src/powershell/Public/Start-FinOpsMultitool.ps1 b/src/powershell/Public/Start-FinOpsMultitool.ps1
new file mode 100644
index 000000000..eee416387
--- /dev/null
+++ b/src/powershell/Public/Start-FinOpsMultitool.ps1
@@ -0,0 +1,55 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+<#
+ .SYNOPSIS
+ Launches the Azure FinOps Multitool interactive GUI.
+
+ .DESCRIPTION
+ The Start-FinOpsMultitool command launches a WPF-based GUI application that scans
+ an Azure tenant for cost optimization, governance, and FinOps insights. The tool
+ authenticates to Azure, discovers all subscriptions, and runs a comprehensive scan
+ covering cost trends, orphaned resources, idle VMs, tag hygiene, reservation and
+ savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly
+ alerts, and policy compliance.
+
+ Results are displayed in an interactive dashboard with export options for Excel,
+ CSV, JSON, and Power BI.
+
+ This command requires Windows with WPF support (PowerShell 5.1+ on Windows or
+ PowerShell 7+ with Windows Compatibility). It is not supported on Linux or macOS.
+
+ .EXAMPLE
+ Start-FinOpsMultitool
+
+ Launches the FinOps Multitool GUI. You will be prompted to authenticate and
+ select a tenant to scan.
+
+ .LINK
+ https://aka.ms/ftk/Start-FinOpsMultitool
+#>
+function Start-FinOpsMultitool {
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Start-FinOpsMultitool launches a read-only GUI scanner and does not modify system state.')]
+ [OutputType([void])]
+ param()
+
+ # Validate Windows + WPF availability
+ if (-not $IsWindows -and $PSVersionTable.PSEdition -eq 'Core') {
+ Write-Error "Start-FinOpsMultitool requires Windows with WPF support. It is not supported on Linux or macOS."
+ return
+ }
+
+ # Locate the Multitool implementation
+ $multitoolRoot = Join-Path -Path $PSScriptRoot -ChildPath '../Private/FinOpsMultitool'
+ $mainScript = Join-Path -Path $multitoolRoot -ChildPath 'Start-FinOpsMultitool.ps1'
+
+ if (-not (Test-Path -Path $mainScript)) {
+ Write-Error "FinOps Multitool files not found at '$multitoolRoot'. The module installation may be incomplete."
+ return
+ }
+
+ # Launch the Multitool in its own scope so $PSScriptRoot resolves correctly
+ # and $script: variables don't leak into the module scope
+ & $mainScript
+}
diff --git a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1
new file mode 100644
index 000000000..12d8d300c
--- /dev/null
+++ b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1
@@ -0,0 +1,53 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+& "$PSScriptRoot/../Initialize-Tests.ps1"
+
+InModuleScope 'FinOpsToolkit' {
+ Describe 'Start-FinOpsMultitool' {
+
+ Context 'Command availability' {
+ It 'Should be exported as a public command' {
+ $cmd = Get-Command -Name 'Start-FinOpsMultitool' -Module 'FinOpsToolkit' -ErrorAction SilentlyContinue
+ $cmd | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Should have CmdletBinding attribute' {
+ $cmd = Get-Command -Name 'Start-FinOpsMultitool' -Module 'FinOpsToolkit'
+ $cmd.CmdletBinding | Should -BeTrue
+ }
+ }
+
+ Context 'File dependencies' {
+ It 'Should have Multitool implementation files' {
+ $privatePath = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/Start-FinOpsMultitool.ps1'
+ Test-Path -Path $privatePath | Should -BeTrue
+ }
+
+ It 'Should have GUI XAML file' {
+ $xamlPath = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/gui/MainWindow.xaml'
+ Test-Path -Path $xamlPath | Should -BeTrue
+ }
+
+ It 'Should have all scanner module files' {
+ $modulesPath = Join-Path -Path $PSScriptRoot -ChildPath '../../Private/FinOpsMultitool/modules'
+ $modules = Get-ChildItem -Path $modulesPath -Filter '*.ps1'
+ $modules.Count | Should -BeGreaterOrEqual 20
+ }
+ }
+
+ Context 'Non-Windows behavior' {
+ It 'Should write an error on non-Windows platforms' {
+ # Simulate non-Windows by mocking the platform check
+ if ($IsWindows -eq $false -or $PSVersionTable.PSEdition -ne 'Core')
+ {
+ Set-ItResult -Skipped -Because 'Test only applicable on non-Windows PowerShell Core'
+ return
+ }
+
+ # On actual non-Windows, the command should emit an error
+ { Start-FinOpsMultitool -ErrorAction Stop } | Should -Throw '*requires Windows*'
+ }
+ }
+ }
+}