scripts/dashboard/Get-AdeCostDashboard.ps1

#Requires -Version 7
<#
.SYNOPSIS
    Azure Demo Environment (ADE) — Cost & Status Dashboard

.DESCRIPTION
    Displays a terminal dashboard showing:
      - Current month cost per resource group
      - Estimated month-end projected cost
      - Running/deallocated/stopped VM status
      - Database and AKS cluster status
      - Budget alert utilisation

.PARAMETER Prefix
    The prefix used during deployment. When omitted, the script prompts for it.
    Press Enter at the prompt to use the default: ade.

.PARAMETER SubscriptionId
    Target subscription ID. Defaults to current az account.

.PARAMETER StopAll
    Stop (deallocate) all VMs and scale sets immediately.

.PARAMETER StartAll
    Start all VMs and scale sets immediately.

.PARAMETER Watch
    Refresh the dashboard every 60 seconds.

.EXAMPLE
    ./dashboard/Get-AdeCostDashboard.ps1
    ./dashboard/Get-AdeCostDashboard.ps1 -Prefix ade
    ./dashboard/Get-AdeCostDashboard.ps1 -Prefix ade -StopAll
    ./dashboard/Get-AdeCostDashboard.ps1 -Prefix ade -Watch
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [ValidatePattern('(?-i)^[a-z0-9]{2,8}$')]
    [string]$Prefix = 'ade',

    [Parameter(Mandatory = $false)]
    [string]$SubscriptionId = '',

    [Parameter(Mandatory = $false)]
    [switch]$StopAll,

    [Parameter(Mandatory = $false)]
    [switch]$StartAll,

    [Parameter(Mandatory = $false)]
    [switch]$Watch
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$scriptRoot = $PSScriptRoot
. "$scriptRoot\..\helpers\common.ps1"

# Honour the standard -Verbose switch: enables Debug-level console output
$script:AdeVerbose = ($VerbosePreference -eq 'Continue')

function Read-AdeDashboardPrefix {
    param([Parameter(Mandatory)][string]$DefaultPrefix)

    while ($true) {
        $entered = Read-Host "Enter ADE resource prefix [$DefaultPrefix]"
        $candidate = if ([string]::IsNullOrWhiteSpace($entered)) { $DefaultPrefix } else { $entered.Trim() }

        if ($candidate -match '^[a-z0-9]{2,8}$') {
            return $candidate
        }

        Write-Host " Prefix must be 2-8 lowercase letters/numbers, for example 'ade' or 'adetest'." -ForegroundColor Yellow
    }
}

if (-not $PSBoundParameters.ContainsKey('Prefix')) {
    $Prefix = Read-AdeDashboardPrefix -DefaultPrefix $Prefix
}

if ($SubscriptionId) {
    az account set --subscription $SubscriptionId --output none
    if ($LASTEXITCODE -ne 0) { throw "Could not switch to subscription '$SubscriptionId'. Verify az login and access." }
}
$sub = az account show --output json | ConvertFrom-Json
$SubscriptionId = $sub.id

# Returns $true when an Azure resource object has tags.managedBy = 'ade'.
# Safe against $null tags or missing keys (common on non-ADE resource groups).
function Test-AdeManagedBy {
    param([psobject]$Resource)
    $null -ne $Resource.tags -and
    $Resource.tags.PSObject.Properties['managedBy'] -and
    $Resource.tags.managedBy -eq 'ade'
}

function Show-AdeDashboard {

    $now    = Get-Date
    $year   = $now.Year
    $month  = $now.Month
    $daysInMonth    = [DateTime]::DaysInMonth($year, $month)
    $daysPassed     = $now.Day
    $budgetRespData = $null  # populated in cost section; reused by budget section

    # ── Header ────────────────────────────────────────────────────────────────
    Write-Host ""
    Write-Host "╔══════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
    Write-Host "║ Azure Demo Environment — Cost & Status Dashboard ║" -ForegroundColor Cyan
    Write-Host "║ Subscription : $($sub.name.PadRight(48))║" -ForegroundColor Cyan
    Write-Host "║ Refreshed : $(($now.ToString('yyyy-MM-dd HH:mm:ss')).PadRight(48))║" -ForegroundColor Cyan
    Write-Host "╚══════════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
    Write-Host ""

    # ── Resource group costs ──────────────────────────────────────────────────
    Write-Host " COSTS — Current Month ($($now.ToString('MMMM yyyy')))" -ForegroundColor Yellow
    Write-Host " ─────────────────────────────────────────────────────────────────" -ForegroundColor DarkGray
    Write-Host " Note: Azure Cost Management has an 8–48 h ingestion lag. Freshly" -ForegroundColor DarkGray
    Write-Host " deployed resources will show `$0.00 until billing data is processed." -ForegroundColor DarkGray

    $rgsRaw = az group list -o json 2>$null | ConvertFrom-Json
    $rgs = $rgsRaw | Where-Object { (Test-AdeManagedBy $_) -and $_.name -like "$Prefix-*" } |
        Select-Object -ExpandProperty name

    if (-not $rgs) {
        Write-Host " No ADE resource groups found for prefix '$Prefix'." -ForegroundColor Gray
    } else {
        # Pre-fetch the budget amount so cost colours are relative to the budget.
        # Green actual = MTD spend is on pace (≤ expected for this day of month)
        # Yellow actual = slightly over pace (up to 20% above expected)
        # Red actual = significantly over pace (>20% above expected)
        # Green projected = end-of-month will stay below 85% of budget
        # Yellow projected = 85-100% of budget
        # Red projected = will exceed budget
        $budgetAmount   = 0.0
        $budgetRestUrl  = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Consumption/budgets?api-version=2023-11-01"
        $budgetRespData = $null
        try {
            $budgetRaw = az rest --method GET --url $budgetRestUrl 2>$null
            if ($LASTEXITCODE -eq 0 -and $budgetRaw) {
                $budgetRespData = $budgetRaw | ConvertFrom-Json -ErrorAction SilentlyContinue
                if ($budgetRespData -and $budgetRespData.value) {
                    $adeBudget = @($budgetRespData.value | Where-Object { $_.name -like "*$Prefix*" }) | Select-Object -First 1
                    if ($adeBudget) { $budgetAmount = [double]$adeBudget.properties.amount }
                }
            }
        } catch {}

        $totalActual    = 0.0
        $totalProjected = 0.0
        $rgCount        = ($rgs | Measure-Object).Count

        # Single REST call to get all RG costs at once (grouped by ResourceGroupName).
        # Uses the Microsoft.CostManagement/query REST API — no CLI extension needed.
        $rgCosts  = @{}   # rg-name → actual cost (DKK/USD etc.)
        $fromDate = "$year-$($month.ToString('D2'))-01"
        $toDate   = $now.ToString('yyyy-MM-dd')
        $costBodyObj = @{
            type      = 'Usage'
            timeframe = 'Custom'
            timePeriod = @{ from = $fromDate; to = $toDate }
            dataset = @{
                granularity = 'None'
                aggregation = @{ totalCost = @{ name = 'PreTaxCost'; function = 'Sum' } }
                grouping    = @( @{ type = 'Dimension'; name = 'ResourceGroupName' } )
            }
        }
        $costBodyFile = New-AdeTempJsonPath -Prefix 'ade' -Purpose 'cost-query'
        [System.IO.File]::WriteAllText(
            $costBodyFile,
            ($costBodyObj | ConvertTo-Json -Depth 10 -Compress),
            [System.Text.UTF8Encoding]::new($false)
        )
        try {
            $costUrl = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
            $costRaw = az rest --method POST --url $costUrl --body "@$costBodyFile" --headers 'Content-Type=application/json' 2>$null
            $costEc  = $LASTEXITCODE
            if ($costEc -eq 0 -and $costRaw) {
                $costResp = $costRaw | ConvertFrom-Json -ErrorAction SilentlyContinue
                # Columns: [PreTaxCost, ResourceGroupName, Currency]
                $rgIdx = ($costResp.properties.columns | ForEach-Object { $_.name }).IndexOf('ResourceGroupName')
                $costIdx = ($costResp.properties.columns | ForEach-Object { $_.name }).IndexOf('PreTaxCost')
                if ($rgIdx -ge 0 -and $costIdx -ge 0) {
                    foreach ($row in @($costResp.properties.rows)) {
                        $rgName = [string]$row[$rgIdx]
                        if ($rgName) { $rgCosts[$rgName] = [double]$row[$costIdx] }
                    }
                }
            } else {
                Write-AdeLog "Cost Management query returned no data. Costs will show as zero unless your account has cost-reader access and billing data is available." -Level Warning
            }
        } finally {
            if (Test-Path $costBodyFile) { Remove-Item $costBodyFile -Force -ErrorAction SilentlyContinue }
        }

        foreach ($rg in ($rgs | Sort-Object)) {
            $actual = if ($rgCosts.ContainsKey($rg)) { $rgCosts[$rg] } else { 0.0 }
            $projected = if ($daysPassed -gt 0) { ($actual / $daysPassed) * $daysInMonth } else { 0.0 }

            $totalActual    += $actual
            $totalProjected += $projected

            $moduleName   = $rg -replace "^$Prefix-" -replace '-rg$'
            $bar          = '█' * [Math]::Min([int]$projected, 40)

            # Actual: neutral (it's a backward-looking fact; pace is shown on the TOTAL row)
            $actualColor = 'White'

            # Projected: budget-relative per-RG share when budget is known; else fixed thresholds
            $projectedColor = if ($budgetAmount -gt 0 -and $rgCount -gt 0) {
                $share = $budgetAmount / $rgCount
                if ($projected -gt $share)              { 'Red'    }
                elseif ($projected -gt $share * 0.75)   { 'Yellow' }
                else                                    { 'Green'  }
            } else {
                if ($projected -gt 100) { 'Red' } elseif ($projected -gt 50) { 'Yellow' } else { 'Green' }
            }

            $actualFmt    = $actual.ToString('C2').PadLeft(8)
            $projectedFmt = $projected.ToString('C2').PadLeft(8)

            Write-Host (" {0,-20} Actual: " -f $moduleName) -NoNewline -ForegroundColor DarkGray
            Write-Host $actualFmt                             -NoNewline -ForegroundColor $actualColor
            Write-Host ' Projected: '                       -NoNewline -ForegroundColor DarkGray
            Write-Host ("{0} {1}" -f $projectedFmt, $bar)              -ForegroundColor $projectedColor
        }

        Write-Host " ─────────────────────────────────────────────────────────────────" -ForegroundColor DarkGray
        $tActualFmt = $totalActual.ToString('C2').PadLeft(8)
        $tProjFmt   = $totalProjected.ToString('C2').PadLeft(8)

        # Total actual: is MTD spend on pace relative to the budget?
        $totalActualColor = if ($budgetAmount -gt 0 -and $daysPassed -gt 0) {
            $expectedNow = $budgetAmount * ($daysPassed / $daysInMonth)
            if ($totalActual -gt $expectedNow * 1.2)  { 'Red'    }
            elseif ($totalActual -gt $expectedNow)    { 'Yellow' }
            else                                      { 'Green'  }
        } else { 'White' }

        # Total projected: will end-of-month spend stay within budget?
        $totalProjectedColor = if ($budgetAmount -gt 0) {
            if ($totalProjected -gt $budgetAmount)              { 'Red'    }
            elseif ($totalProjected -gt $budgetAmount * 0.85)  { 'Yellow' }
            else                                               { 'Green'  }
        } else { 'White' }

        Write-Host " $('TOTAL'.PadRight(20)) Actual: " -NoNewline -ForegroundColor DarkGray
        Write-Host $tActualFmt                          -NoNewline -ForegroundColor $totalActualColor
        Write-Host ' Projected: '                      -NoNewline -ForegroundColor DarkGray
        Write-Host $tProjFmt                                       -ForegroundColor $totalProjectedColor
    }

    Write-Host ""

    # ── VM Status ─────────────────────────────────────────────────────────────
    Write-Host " VIRTUAL MACHINES" -ForegroundColor Yellow
    Write-Host " ─────────────────────────────────────────────────────────────────" -ForegroundColor DarkGray

    $vmsRaw = az vm list -o json 2>$null | ConvertFrom-Json
    $vms = $vmsRaw | Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object name, @{n='rg';e={$_.resourceGroup}}, @{n='size';e={$_.hardwareProfile.vmSize}}

    if ($vms) {
        foreach ($vm in $vms) {
            $iv         = az vm get-instance-view `
                --resource-group $vm.rg `
                --name $vm.name `
                -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue
            $powerState = ($iv.instanceView.statuses | Where-Object { $_.code -like 'PowerState/*' }).displayStatus

            $stateColor = switch -Wildcard ($powerState) {
                '*running*'     { 'Green'  }
                '*deallocated*' { 'Gray'   }
                '*stopped*'     { 'Yellow' }
                default         { 'White'  }
            }

            $indicator = switch -Wildcard ($powerState) {
                '*running*'     { '▶ RUNNING ' }
                '*deallocated*' { '■ DEALLOCATED' }
                '*stopped*'     { '■ STOPPED ' }
                default         { '? UNKNOWN ' }
            }

            Write-Host (" {0,-30} {1,-15} {2}" -f $vm.name, $vm.size, $indicator) -ForegroundColor $stateColor
        }
    } else {
        Write-Host " No VMs found." -ForegroundColor Gray
    }

    Write-Host ""

    # ── AKS Clusters ─────────────────────────────────────────────────────────
    Write-Host " AKS CLUSTERS" -ForegroundColor Yellow
    Write-Host " ─────────────────────────────────────────────────────────────────" -ForegroundColor DarkGray

    $clustersRaw = az aks list -o json 2>$null | ConvertFrom-Json
    $clusters = $clustersRaw | Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object name, @{n='rg';e={$_.resourceGroup}}, @{n='powerState';e={$_.powerState.code}}, @{n='version';e={$_.kubernetesVersion}}, @{n='nodeCount';e={$_.agentPoolProfiles[0].count}}

    if ($clusters) {
        foreach ($c in $clusters) {
            $stateColor = if ($c.powerState -eq 'Running') { 'Green' } else { 'Yellow' }
            Write-Host (" {0,-30} k8s {1} Nodes: {2} [{3}]" -f $c.name, $c.version, $c.nodeCount, $c.powerState) -ForegroundColor $stateColor
        }
    } else {
        Write-Host " No AKS clusters found." -ForegroundColor Gray
    }

    Write-Host ""

    # ── Integration resources ─────────────────────────────────────────────────
    $sbRaw        = az servicebus namespace list -o json 2>$null | ConvertFrom-Json
    $sbNamespaces = $sbRaw | Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object name, @{n='sku';e={$_.sku.name}}, @{n='rg';e={$_.resourceGroup}}

    $ehRaw        = az eventhubs namespace list -o json 2>$null | ConvertFrom-Json
    $ehNamespaces = $ehRaw | Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object name, @{n='sku';e={$_.sku.name}}, @{n='rg';e={$_.resourceGroup}}

    $caRaw  = az containerapp env list -o json 2>$null | ConvertFrom-Json
    $caEnvs = $caRaw | Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object name, @{n='rg';e={$_.resourceGroup}}

    $adfRaw = az resource list --resource-type 'Microsoft.DataFactory/factories' -o json 2>$null | ConvertFrom-Json
    $adfs   = $adfRaw | Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object name, @{n='rg';e={$_.resourceGroup}}

    if ($sbNamespaces -or $ehNamespaces -or $caEnvs -or $adfs) {
        Write-Host " INTEGRATION & DATA" -ForegroundColor Yellow
        Write-Host " ─────────────────────────────────────────────────────────────────" -ForegroundColor DarkGray

        foreach ($sb in $sbNamespaces) {
            Write-Host (" [ServiceBus ] {0,-35} SKU: {1}" -f $sb.name, $sb.sku) -ForegroundColor Cyan
        }
        foreach ($eh in $ehNamespaces) {
            Write-Host (" [EventHub ] {0,-35} SKU: {1}" -f $eh.name, $eh.sku) -ForegroundColor Cyan
        }
        foreach ($ca in $caEnvs) {
            Write-Host (" [ContainerApp] {0,-35}" -f $ca.name) -ForegroundColor Cyan
        }
        foreach ($adf in $adfs) {
            Write-Host (" [DataFactory] {0,-35}" -f $adf.name) -ForegroundColor Cyan
        }
        Write-Host ""
    }

    # ── Budget ────────────────────────────────────────────────────────────────
    Write-Host " BUDGET ALERTS" -ForegroundColor Yellow
    Write-Host " ─────────────────────────────────────────────────────────────────" -ForegroundColor DarkGray

    # Reuse the REST response already fetched for cost colouring; fall back to a fresh call
    # if Show-AdeDashboard is called outside the rgs-else block (e.g. no ADE RGs deployed yet).
    $budgetsResp = if ($null -ne $budgetRespData) { $budgetRespData } else {
        try {
            $url = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Consumption/budgets?api-version=2023-11-01"
            az rest --method GET --url $url 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue
        } catch { $null }
    }
    $budgets = if ($budgetsResp -and $budgetsResp.value) {
        @($budgetsResp.value | Where-Object { $_.name -like "*$Prefix*" } | ForEach-Object {
            [pscustomobject]@{
                name         = $_.name
                amount       = [double]$_.properties.amount
                currentSpend = [double]$_.properties.currentSpend.amount
                timeGrain    = $_.properties.timeGrain
            }
        })
    } else { $null }

    if ($budgets) {
        foreach ($b in $budgets) {
            $pct = if ($b.amount -gt 0) { [int](($b.currentSpend / $b.amount) * 100) } else { 0 }
            $barFilled = '█' * [Math]::Min([int]($pct / 2), 50)
            $barEmpty  = '░' * (50 - $barFilled.Length)
            $color     = if ($pct -ge 100) { 'Red' } elseif ($pct -ge 80) { 'Yellow' } else { 'Green' }

            $spendFmt  = $b.currentSpend.ToString('N0').PadLeft(8)
            $amountFmt = $b.amount.ToString('N0').PadLeft(8)
            $pctFmt    = ([string]$pct).PadLeft(3)
            $budgetLine = (" {0,-25} [{1}{2}] {3}%" -f $b.name, $barFilled, $barEmpty, $pctFmt) + " $spendFmt / $amountFmt"
            Write-Host $budgetLine -ForegroundColor $color
        }
    } else {
        Write-Host " No budgets found. Deploy governance module to enable budget alerts." -ForegroundColor Gray
    }

    Write-Host ""
    Write-Host " ACTIONS" -ForegroundColor DarkGray
    Write-Host " -StopAll Deallocate all VMs + scale sets now" -ForegroundColor DarkGray
    Write-Host " -StartAll Start all VMs + scale sets now" -ForegroundColor DarkGray
    Write-Host " -Watch Auto-refresh every 60 seconds" -ForegroundColor DarkGray
    Write-Host ""
}

# ── Stop / Start all VMs ──────────────────────────────────────────────────────
if ($StopAll -and $StartAll) {
    throw '-StopAll and -StartAll are mutually exclusive. Specify only one.'
}
if ($StopAll) {
    Write-AdeSection "Stopping All VMs"

    $vmIds = az vm list -o json 2>$null | ConvertFrom-Json |
        Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object -ExpandProperty id

    $vmssIds = az vmss list -o json 2>$null | ConvertFrom-Json |
        Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object -ExpandProperty id

    if ($vmIds) {
        Write-AdeLog "Deallocating VMs..." -Level Warning
        az vm deallocate --ids $vmIds --no-wait --output none
        Write-AdeLog "VM stop commands sent." -Level Success
    }

    if ($vmssIds) {
        Write-AdeLog "Deallocating VMSS..." -Level Warning
        az vmss deallocate --ids $vmssIds --no-wait --output none
        Write-AdeLog "VMSS stop commands sent." -Level Success
    }

    # Stop AKS clusters
    $aksRaw   = az aks list -o json 2>$null | ConvertFrom-Json
    $aksNames = $aksRaw | Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object name, @{n='rg';e={$_.resourceGroup}}

    foreach ($aks in $aksNames) {
        Write-AdeLog "Stopping AKS cluster: $($aks.name)" -Level Warning
        az aks stop --name $aks.name --resource-group $aks.rg --no-wait --output none
    }
}

if ($StartAll) {
    Write-AdeSection "Starting All VMs"

    $vmIds = az vm list -o json 2>$null | ConvertFrom-Json |
        Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object -ExpandProperty id

    $vmssIds = az vmss list -o json 2>$null | ConvertFrom-Json |
        Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object -ExpandProperty id

    if ($vmIds) {
        Write-AdeLog "Starting VMs..." -Level Info
        az vm start --ids $vmIds --no-wait --output none
        Write-AdeLog "VM start commands sent." -Level Success
    }

    if ($vmssIds) {
        Write-AdeLog "Starting VMSS..." -Level Info
        az vmss start --ids $vmssIds --no-wait --output none
        Write-AdeLog "VMSS start commands sent." -Level Success
    }

    $aksRaw   = az aks list -o json 2>$null | ConvertFrom-Json
    $aksNames = $aksRaw | Where-Object { (Test-AdeManagedBy $_) -and $_.resourceGroup -like "$Prefix-*" } |
        Select-Object name, @{n='rg';e={$_.resourceGroup}}

    foreach ($aks in $aksNames) {
        Write-AdeLog "Starting AKS cluster: $($aks.name)" -Level Info
        az aks start --name $aks.name --resource-group $aks.rg --no-wait --output none
    }
}

# ── Pre-flight: Cost Management provider ──────────────────────────────────────
$providerState = az provider show --namespace Microsoft.CostManagement --query 'registrationState' -o tsv 2>$null
if ($providerState -ne 'Registered') {
    Write-Warning "Microsoft.CostManagement provider is not registered in subscription '$($sub.name)'. Cost data will show as zero. Register with: az provider register --namespace Microsoft.CostManagement"
}

# ── Show dashboard ────────────────────────────────────────────────────────────
Show-AdeDashboard

if ($Watch) {
    while ($true) {
        Start-Sleep -Seconds 60
        Show-AdeDashboard
    }
}