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("
Errors
$errorCount
") + } + [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("") } + [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("") + } + [void]$htmlSb.Append('') + } + [void]$htmlSb.Append('
$([System.Net.WebUtility]::HtmlEncode($c))
$enc
') + } + + # Render guidance + # (Re-evaluate guidance items for HTML — reuse the same logic) + } + + [void]$htmlSb.Append('
Generated by FinOps Multitool — part of the FinOps Toolkit
') + [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 = @" + + + + + + + + + + + + + + + + + + + + + + +