From e230be1fa43548dc29fa076497ebacc51c818d5f Mon Sep 17 00:00:00 2001 From: MSBrett Date: Sun, 23 Aug 2026 17:26:19 -0700 Subject: [PATCH 01/18] feat(hubs): add Azure Resource Manager ingestion Add generic ARM query ingestion with quota and Savings Plan definitions, normalized KQL surfaces, catalog queries, documentation, and regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + .../Tests/Unit/HubsIngestionQueries.Tests.ps1 | 376 +++++- src/queries/INDEX.md | 12 +- .../catalog/quota-app-service-usage.kql | 34 + .../catalog/quota-capacity-reservations.kql | 37 + .../quota-cognitive-services-usage.kql | 34 + src/queries/catalog/quota-compute-usage.kql | 34 + src/queries/catalog/quota-current-usage.kql | 30 + src/queries/catalog/quota-headroom.kql | 34 + .../catalog/quota-premium-ssd-v2-disks.kql | 37 + .../catalog/quota-sql-subscription-usage.kql | 34 + src/queries/catalog/quota-storage-usage.kql | 34 + .../savings-plan-recommendation-breakdown.kql | 44 + src/queries/finops-hub-database-guide.md | 51 + src/scripts/Build-HubIngestionQueries.ps1 | 82 +- .../finops-hub/createUiDefinition.json | 98 +- src/templates/finops-hub/main.bicep | 4 + .../Analytics/scripts/HubSetup_Latest.kql | 8 + .../Analytics/scripts/HubSetup_v1_0.kql | 26 + .../scripts/IngestionSetup_RawTables.kql | 63 +- .../Analytics/scripts/IngestionSetup_v1_0.kql | 109 ++ .../Analytics/scripts/IngestionSetup_v1_2.kql | 7 +- .../AzureResourceGraph/app.bicep | 3 + .../AzureResourceManager/README.md | 25 + .../AzureResourceManager/app.bicep | 1095 +++++++++++++++++ .../AzureResourceManager/metadata.bicep | 16 + .../IngestionQueries/app.bicep | 197 ++- .../Microsoft.FinOpsHubs/Quota/app.bicep | 111 ++ .../Quota-Microsoft-AppServiceUsage.json | 10 + .../Quota-Microsoft-CapacityReservation.json | 10 + ...uota-Microsoft-CognitiveServicesUsage.json | 10 + .../queries/Quota-Microsoft-ComputeUsage.json | 10 + .../Quota-Microsoft-PremiumSSDv2Disk.json | 10 + .../Quota-Microsoft-SqlSubscriptionUsage.json | 10 + .../queries/Quota-Microsoft-StorageUsage.json | 10 + .../quota_1.0-capacity-reservation.json | 42 + .../Quota/schemas/quota_1.0-disk.json | 58 + .../Quota/schemas/quota_1.0-sql.json | 73 ++ .../Quota/schemas/quota_1.0-usage.json | 59 + .../Recommendations/app.bicep | 43 +- ...mmendations-Microsoft-SavingsPlan-P1Y.json | 10 + ...mmendations-Microsoft-SavingsPlan-P3Y.json | 10 + .../schemas/recommendations_1.1.json | 91 ++ .../modules/fx/hub-deploymentScript.bicep | 4 + src/templates/finops-hub/modules/hub.bicep | 27 +- src/templates/finops-hub/test/main.test.bicep | 33 +- 46 files changed, 3088 insertions(+), 68 deletions(-) create mode 100644 src/queries/catalog/quota-app-service-usage.kql create mode 100644 src/queries/catalog/quota-capacity-reservations.kql create mode 100644 src/queries/catalog/quota-cognitive-services-usage.kql create mode 100644 src/queries/catalog/quota-compute-usage.kql create mode 100644 src/queries/catalog/quota-current-usage.kql create mode 100644 src/queries/catalog/quota-headroom.kql create mode 100644 src/queries/catalog/quota-premium-ssd-v2-disks.kql create mode 100644 src/queries/catalog/quota-sql-subscription-usage.kql create mode 100644 src/queries/catalog/quota-storage-usage.kql create mode 100644 src/queries/catalog/savings-plan-recommendation-breakdown.kql create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/README.md create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/app.bicep create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/metadata.bicep create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/app.bicep create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-AppServiceUsage.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-CapacityReservation.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-CognitiveServicesUsage.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-ComputeUsage.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-PremiumSSDv2Disk.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-SqlSubscriptionUsage.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-StorageUsage.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-capacity-reservation.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-disk.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-sql.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-usage.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SavingsPlan-P1Y.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SavingsPlan-P3Y.json create mode 100644 src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/schemas/recommendations_1.1.json diff --git a/.gitignore b/.gitignore index 198952770..97fa70d1e 100644 --- a/.gitignore +++ b/.gitignore @@ -374,6 +374,7 @@ env/ # AI .claude/settings.local.json .claude/scheduled_tasks.lock +.copilot-tracking/ # Internal planning docs /TODO.md diff --git a/src/powershell/Tests/Unit/HubsIngestionQueries.Tests.ps1 b/src/powershell/Tests/Unit/HubsIngestionQueries.Tests.ps1 index f3aa5d8b0..4a57a2eec 100644 --- a/src/powershell/Tests/Unit/HubsIngestionQueries.Tests.ps1 +++ b/src/powershell/Tests/Unit/HubsIngestionQueries.Tests.ps1 @@ -5,32 +5,106 @@ Describe 'HubsIngestionQueries' { BeforeDiscovery { $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path - $queriesPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries' - $schemasPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/schemas' - $schemaFileNames = @(Get-ChildItem -Path $schemasPath -Filter '*.json' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) - # Convert FileInfo to hashtables so Pester -ForEach iterates correctly - $queryFiles = @(Get-ChildItem -Path $queriesPath -Filter '*.json' -ErrorAction SilentlyContinue | ForEach-Object { - @{ Name = $_.Name; FullName = $_.FullName; BaseName = $_.BaseName; SchemaFileNames = $schemaFileNames } + $appNames = @('Recommendations', 'Quota') + $queryFiles = @($appNames | ForEach-Object { + $appName = $_ + $appPath = Join-Path $repoRoot "src/templates/finops-hub/modules/Microsoft.FinOpsHubs/$appName" + $schemaFileNames = @(Get-ChildItem -Path "$appPath/schemas" -Filter '*.json' | ForEach-Object { $_.Name }) + + Get-ChildItem -Path "$appPath/queries" -Filter '*.json' | ForEach-Object { + @{ + AppName = $appName + Name = $_.Name + FullName = $_.FullName + BaseName = $_.BaseName + SchemaFileNames = $schemaFileNames + } + } }) - $schemaFiles = @(Get-ChildItem -Path $schemasPath -Filter '*.json' -ErrorAction SilentlyContinue | ForEach-Object { - @{ Name = $_.Name; FullName = $_.FullName; BaseName = $_.BaseName } + $schemaFiles = @($appNames | ForEach-Object { + $appName = $_ + Get-ChildItem -Path (Join-Path $repoRoot "src/templates/finops-hub/modules/Microsoft.FinOpsHubs/$appName/schemas") -Filter '*.json' | ForEach-Object { + @{ AppName = $appName; Name = $_.Name; FullName = $_.FullName; BaseName = $_.BaseName } + } }) + $quotaCatalogContracts = @( + @{ Name = 'quota-app-service-usage.kql'; SourceType = 'AppServiceUsage' } + @{ Name = 'quota-capacity-reservations.kql'; SourceType = 'CapacityReservation' } + @{ Name = 'quota-cognitive-services-usage.kql'; SourceType = 'CognitiveServicesUsage' } + @{ Name = 'quota-compute-usage.kql'; SourceType = 'ComputeUsage' } + @{ Name = 'quota-premium-ssd-v2-disks.kql'; SourceType = 'PremiumSSDv2Disk' } + @{ Name = 'quota-sql-subscription-usage.kql'; SourceType = 'SqlSubscriptionUsage' } + @{ Name = 'quota-storage-usage.kql'; SourceType = 'StorageUsage' } + ) | ForEach-Object { + $_.FullName = Join-Path $repoRoot "src/queries/catalog/$($_.Name)" + $_ + } } BeforeAll { $repoRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path - $queriesPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries' - $schemasPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/schemas' - $queryFileCount = @(Get-ChildItem -Path $queriesPath -Filter '*.json' -ErrorAction SilentlyContinue).Count - $schemaFileCount = @(Get-ChildItem -Path $schemasPath -Filter '*.json' -ErrorAction SilentlyContinue).Count - $knownEngines = @('ResourceGraph') + $appNames = @('Recommendations', 'Quota') + $queryFileCount = @($appNames | ForEach-Object { Get-ChildItem -Path (Join-Path $repoRoot "src/templates/finops-hub/modules/Microsoft.FinOpsHubs/$_/queries") -Filter '*.json' }).Count + $schemaFileCount = @($appNames | ForEach-Object { Get-ChildItem -Path (Join-Path $repoRoot "src/templates/finops-hub/modules/Microsoft.FinOpsHubs/$_/schemas") -Filter '*.json' }).Count + $queryObjects = @($appNames | ForEach-Object { + $appName = $_ + Get-ChildItem -Path (Join-Path $repoRoot "src/templates/finops-hub/modules/Microsoft.FinOpsHubs/$appName/queries") -Filter '*.json' | ForEach-Object { + [pscustomobject]@{ + AppName = $appName + Query = Get-Content -Path $_.FullName -Raw | ConvertFrom-Json + } + } + }) + $knownEngines = @('ResourceGraph', 'AzureResourceManager') $requiredQueryFields = @('dataset', 'provider', 'query', 'queryEngine', 'scope', 'source', 'type', 'version') + $forbiddenQueryFields = @('queryDefinition', 'method', 'headers', 'body', 'authority', 'authenticationResource') # Derive known groups from Recommendations/app.bicep parameters. # Non-core groups need a corresponding "enable{Group}Recommendations" bool parameter in app.bicep. $appBicepPath = Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/app.bicep' $appBicepContent = Get-Content -Path $appBicepPath -Raw - $knownGroups = @('core') + @([regex]::Matches($appBicepContent, 'param enable(\w+)Recommendations bool') | ForEach-Object { $_.Groups[1].Value.ToLower() }) + $recommendationGroups = @('core') + @([regex]::Matches($appBicepContent, 'param enable(\w+)Recommendations bool') | ForEach-Object { $_.Groups[1].Value.ToLower() }) + + $ingestionQueriesContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/IngestionQueries/app.bicep') -Raw + $argEngineContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceGraph/app.bicep') -Raw + $armEngineContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/app.bicep') -Raw + $settingsContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Core/settings.json') -Raw + $rawTablesContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_RawTables.kql') -Raw + $ingestionSetupContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql') -Raw + $ingestionSetupV12Content = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_2.kql') -Raw + $hubSetupContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_0.kql') -Raw + $hubLatestContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_Latest.kql') -Raw + $mainTemplateContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/main.bicep') -Raw + $hubModuleContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/hub.bicep') -Raw + $deploymentScriptContent = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/modules/fx/hub-deploymentScript.bicep') -Raw + $portal = Get-Content -Path (Join-Path $repoRoot 'src/templates/finops-hub/createUiDefinition.json') -Raw | ConvertFrom-Json + $buildScriptContent = Get-Content -Path (Join-Path $repoRoot 'src/scripts/Build-HubIngestionQueries.ps1') -Raw + $savingsPlanCatalogContent = Get-Content -Path (Join-Path $repoRoot 'src/queries/catalog/savings-plan-recommendation-breakdown.kql') -Raw + $quotaCatalogFileNames = @( + 'quota-app-service-usage.kql' + 'quota-capacity-reservations.kql' + 'quota-cognitive-services-usage.kql' + 'quota-compute-usage.kql' + 'quota-premium-ssd-v2-disks.kql' + 'quota-sql-subscription-usage.kql' + 'quota-storage-usage.kql' + ) + $quotaCatalogColumns = @( + 'ProviderName' + 'ResourceId' + 'ResourceName' + 'ResourceType' + 'SubAccountId' + 'displayName' + 'location' + 'currentValue' + 'limit' + 'unit' + 'x_QuotaDetails' + 'x_SourceType' + 'x_SourceVersion' + 'x_IngestionTime' + ) } Context 'Query files' { @@ -59,19 +133,28 @@ Describe 'HubsIngestionQueries' { } } + It 'Should not configure ARM request behavior: ' -ForEach $queryFiles { + $json = Get-Content -Path $FullName -Raw | ConvertFrom-Json + foreach ($field in $forbiddenQueryFields) + { + $json.PSObject.Properties.Name | Should -Not -Contain $field -Because "query file '$Name' must use the engine's fixed request behavior" + } + } + It 'Should use a known query engine: ' -ForEach $queryFiles { $json = Get-Content -Path $FullName -Raw | ConvertFrom-Json $json.queryEngine | Should -BeIn $knownEngines -Because "queryEngine '$($json.queryEngine)' in '$Name' is not a known engine ($($knownEngines -join ', '))" } It 'Should match naming convention: ' -ForEach $queryFiles { - $Name | Should -Match '^[A-Za-z]+-[A-Za-z]+-[A-Za-z0-9]+\.json$' -Because "query file '$Name' should follow the '{Dataset}-{Provider}-{Name}.json' naming convention" + $Name | Should -Match '^[A-Za-z]+-[A-Za-z]+-[A-Za-z0-9-]+\.json$' -Because "query file '$Name' should follow the '{Dataset}-{Provider}-{Name}.json' naming convention" } It 'Should use a known query group: ' -ForEach $queryFiles { $json = Get-Content -Path $FullName -Raw | ConvertFrom-Json $group = if ($json.PSObject.Properties['group'] -and $json.group) { $json.group } else { 'core' } - $group | Should -BeIn $knownGroups -Because "query group '$group' in '$Name' is not a known group ($($knownGroups -join ', ')). Add the group to Build-HubIngestionQueries.ps1 `$groupConfig` and Recommendations/app.bicep before using it." + $knownGroups = if ($AppName -eq 'Recommendations') { $recommendationGroups } else { @('core') } + $group | Should -BeIn $knownGroups -Because "query group '$group' in '$Name' is not a known group ($($knownGroups -join ', '))." } It 'Should be consistent with dataset field: ' -ForEach $queryFiles { @@ -114,9 +197,48 @@ Describe 'HubsIngestionQueries' { $mapping.PSObject.Properties.Name | Should -Contain 'source' -Because 'each mapping needs a source' $mapping.PSObject.Properties.Name | Should -Contain 'sink' -Because 'each mapping needs a sink' $mapping.source.path | Should -Not -BeNullOrEmpty -Because 'source path should not be empty' - $mapping.sink.path | Should -Not -BeNullOrEmpty -Because 'sink path should not be empty' + ($mapping.sink.name -or $mapping.sink.path) | Should -BeTrue -Because 'sink name or path should not be empty' } } + + It 'Should use sink.name for new ARM tabular schemas: ' -ForEach ($schemaFiles | Where-Object { $_.Name -in @('recommendations_1.1.json', 'quota_1.0-capacity-reservation.json', 'quota_1.0-disk.json', 'quota_1.0-sql.json', 'quota_1.0-usage.json') }) { + $json = Get-Content -Path $FullName -Raw | ConvertFrom-Json + @($json.translator.mappings | Where-Object { -not $_.sink.name -or $_.sink.path }).Count | Should -Be 0 + } + + It 'Should construct quota details in KQL instead of the REST translator: ' -ForEach ($schemaFiles | Where-Object { $_.AppName -eq 'Quota' }) { + $json = Get-Content -Path $FullName -Raw | ConvertFrom-Json + @($json.translator.mappings | Where-Object { $_.sink.name -eq 'x_QuotaDetails' }).Count | Should -Be 0 + } + + It 'Should not map one REST source path more than once: ' -ForEach ($schemaFiles | Where-Object { $_.Name -in @('recommendations_1.1.json', 'quota_1.0-capacity-reservation.json', 'quota_1.0-disk.json', 'quota_1.0-sql.json', 'quota_1.0-usage.json') }) { + $json = Get-Content -Path $FullName -Raw | ConvertFrom-Json + $sourcePaths = @($json.translator.mappings | ForEach-Object { $_.source.path }) + @($sourcePaths | Group-Object | Where-Object { $_.Count -gt 1 }).Count | Should -Be 0 + } + + It 'Should define sink types for empty Savings Plan responses' -ForEach ($schemaFiles | Where-Object { $_.Name -eq 'recommendations_1.1.json' }) { + $schema = Get-Content -Path $FullName -Raw | ConvertFrom-Json + + @($schema.translator.mappings | Where-Object { -not $_.sink.type }).Count | Should -Be 0 + @($schema.translator.mappings | Where-Object { $_.sink.name -in @('x_EffectiveCostBefore', 'x_EffectiveCostAfter', 'x_EffectiveCostSavings') -and $_.sink.type -ne 'Double' }).Count | Should -Be 0 + @($schema.translator.mappings | Where-Object { $_.sink.name -notin @('x_EffectiveCostBefore', 'x_EffectiveCostAfter', 'x_EffectiveCostSavings') -and $_.sink.type -ne 'String' }).Count | Should -Be 0 + } + + It 'Should define sink types for empty SQL responses' -ForEach ($schemaFiles | Where-Object { $_.Name -eq 'quota_1.0-sql.json' }) { + $schema = Get-Content -Path $FullName -Raw | ConvertFrom-Json + + @($schema.translator.mappings | Where-Object { -not $_.sink.type }).Count | Should -Be 0 + ($schema.translator.mappings | Where-Object { $_.sink.name -eq 'currentValue' }).sink.type | Should -Be 'Double' + ($schema.translator.mappings | Where-Object { $_.sink.name -eq 'limit' }).sink.type | Should -Be 'Double' + @($schema.translator.mappings | Where-Object { $_.sink.name -notin @('currentValue', 'limit') -and $_.sink.type -ne 'String' }).Count | Should -Be 0 + } + + It 'Should define the ResourceId sink type for usage responses without IDs' -ForEach ($schemaFiles | Where-Object { $_.Name -eq 'quota_1.0-usage.json' }) { + $schema = Get-Content -Path $FullName -Raw | ConvertFrom-Json + + ($schema.translator.mappings | Where-Object { $_.sink.name -eq 'ResourceId' }).sink.type | Should -Be 'String' + } } Context 'Query-to-schema consistency' { @@ -128,6 +250,226 @@ Describe 'HubsIngestionQueries' { } } + Context 'Template boundaries' { + + It 'Should rerun storage deployment scripts when files change' { + $deploymentScriptContent | Should -Match 'param forceUpdateTag string = utcNow\(\)' + $deploymentScriptContent | Should -Match 'forceUpdateTag: forceUpdateTag' + } + + It 'Should keep one shared query loop without engine-specific routing' { + [regex]::Matches($ingestionQueriesContent, "name: 'Loop Thru Queries'").Count | Should -Be 1 + $ingestionQueriesContent | Should -Not -Match 'pipeline_(AzureResourceManager|ResourceGraph)' + } + + It 'Should match managed export query concurrency limits' { + $ingestionQueriesContent | Should -Match 'batchCount: app\.hub\.options\.privateRouting \? 4 : 30' + } + + It 'Should keep the ARM engine as one GET Copy with native paging' { + [regex]::Matches($armEngineContent, "type: 'Copy'").Count | Should -Be 1 + $armEngineContent | Should -Match "requestMethod: 'GET'" + $armEngineContent | Should -Match "AbsoluteUrl: '\$\.nextLink'" + $armEngineContent | Should -Not -Match 'additionalColumns|requestBody|additionalHeaders|queryDefinition' + $armEngineContent | Should -Not -Match "type: 'Until'" + } + + It 'Should pass queryScope through the shared engine contract' { + $ingestionQueriesContent | Should -Match '"queryScope":"' + $argEngineContent | Should -Match '(?s)queryScope:\s*\{\s*type:\s*''String''' + $armEngineContent | Should -Match '(?s)queryScope:\s*\{\s*type:\s*''String''' + } + + It 'Should not add subscription scopes to settings' { + $settingsContent | Should -Not -Match '"subscriptions"\s*:' + } + + It 'Should normalize the configured billing scope loader without expected activity failures' { + $armEngineContent | Should -Match "name: 'Get Config'" + $armEngineContent | Should -Match "name: 'Set Scopes'" + $armEngineContent | Should -Match "name: 'Filter Invalid Scopes'" + $armEngineContent | Should -Match 'dataset_config\.name' + $armEngineContent | Should -Match 'microsoft\.billing' + $armEngineContent | Should -Match ([regex]::Escape("@if(startswith(string(activity(\'Get Config\').output.firstRow.scopes), \'[\'), activity(\'Get Config\').output.firstRow.scopes, createArray(activity(\'Get Config\').output.firstRow.scopes))")) + $armEngineContent | Should -Not -Match "name: 'Set Scopes as Array'" + $armEngineContent | Should -Not -Match 'billingScopes' + } + + It 'Should use fixed tenant and physical region discovery' { + $armEngineContent | Should -Match 'subscriptions\?api-version=2022-12-01' + $armEngineContent | Should -Match 'locations\?api-version=2022-12-01' + $armEngineContent | Should -Match 'providers/.+api-version=2021-04-01' + $armEngineContent | Should -Match 'item\(\)\.state' + $armEngineContent | Should -Match 'item\(\)\.metadata\.regionType' + $armEngineContent | Should -Match 'Filter Provider Resource Type' + $armEngineContent | Should -Match 'item\(\)\.displayName' + $armEngineContent | Should -Match '\{location\}' + } + + It 'Should use the copied parallel child pipeline boundary' { + [regex]::Matches($armEngineContent, "type: 'ForEach'").Count | Should -Be 3 + [regex]::Matches($armEngineContent, 'batchCount: app\.hub\.options\.privateRouting \? 4 : 30').Count | Should -Be 3 + $armEngineContent | Should -Match "(?s)type: 'ForEach'.*?activities: \[\s*\{\s*name: 'Execute" + $armEngineContent | Should -Match 'waitOnCompletion: true' + } + + It 'Should preserve request context in the output identity' { + $armEngineContent | Should -Match 'parameters\.queryScope' + $armEngineContent | Should -Match 'parameters\.queryLocation' + $armEngineContent | Should -Match 'parameters\.queryVersion' + $armEngineContent | Should -Match ([regex]::Escape("pipeline().parameters.queryType, \'--\', pipeline().parameters.queryVersion, \'--\', replace(pipeline().parameters.queryScope, \'/\', \'_\'), \'--\', pipeline().parameters.queryLocation")) + } + + It 'Should not dispatch on query metadata' { + [regex]::Matches($armEngineContent, 'queryDataset').Count | Should -Be 1 + [regex]::Matches($armEngineContent, 'queryProvider').Count | Should -Be 1 + [regex]::Matches($armEngineContent, 'queryEngine').Count | Should -Be 1 + [regex]::Matches($armEngineContent, 'querySource').Count | Should -Be 1 + $armEngineContent | Should -Not -Match "type: 'Switch'" + } + } + + Context 'Savings Plan and quota contracts' { + + It 'Should define both unfiltered Savings Plan terms on configured billing scopes' { + $savingsPlanQueries = @($queryObjects | Where-Object { $_.AppName -eq 'Recommendations' -and $_.Query.type -like 'Microsoft-SavingsPlan-*' }) + + $savingsPlanQueries.Count | Should -Be 2 + @($savingsPlanQueries.Query.query | Where-Object { $_ -match 'armSkuName' }).Count | Should -Be 0 + @($savingsPlanQueries.Query.query | Where-Object { $_ -match "term eq 'P1Y'" }).Count | Should -Be 1 + @($savingsPlanQueries.Query.query | Where-Object { $_ -match "term eq 'P3Y'" }).Count | Should -Be 1 + @($savingsPlanQueries.Query.scope | Sort-Object -Unique) | Should -Be @('Configured') + @($savingsPlanQueries.Query.queryEngine | Sort-Object -Unique) | Should -Be @('AzureResourceManager') + } + + It 'Should derive the Savings Plan recommendation ID from the single mapped ARM ID' { + $ingestionSetupV12Content | Should -Match 'x_RecommendationId = coalesce\(x_RecommendationId, ResourceId\)' + } + + It 'Should derive the Savings Plan benefit type from the documented ARM SKU name' { + $savingsPlanCatalogContent | Should -Match 'BenefitType = replace_regex\(tostring\(x_RecommendationDetails\.x_ArmSkuName\)' + $savingsPlanCatalogContent | Should -Not -Match 'x_RecommendationType' + } + + It 'Should define the seven approved GET-only quota queries' { + $quotaQueries = @($queryObjects | Where-Object { $_.AppName -eq 'Quota' }) + + $quotaQueries.Count | Should -Be 7 + @($quotaQueries.Query.type | Sort-Object) | Should -Be @( + 'AppServiceUsage' + 'CapacityReservation' + 'CognitiveServicesUsage' + 'ComputeUsage' + 'PremiumSSDv2Disk' + 'SqlSubscriptionUsage' + 'StorageUsage' + ) + @($quotaQueries.Query.queryEngine | Sort-Object -Unique) | Should -Be @('AzureResourceManager') + @($quotaQueries.Query.scope | Sort-Object -Unique) | Should -Be @('Tenant') + @($quotaQueries.Query.query | Where-Object { $_ -notmatch '^/providers/' }).Count | Should -Be 0 + } + + It 'Should define only the seven approved type-specific quota catalog files' { + $catalogPath = Join-Path $repoRoot 'src/queries/catalog' + $aggregateFiles = @('quota-current-usage.kql', 'quota-headroom.kql') + $actualFiles = @(Get-ChildItem -Path $catalogPath -Filter 'quota-*.kql' | + Where-Object { $_.Name -notin $aggregateFiles } | + Select-Object -ExpandProperty Name | + Sort-Object) + $expectedFiles = @($quotaCatalogFileNames | Sort-Object) + + $actualFiles | Should -Be $expectedFiles + } + + It 'Should rehydrate in ' -ForEach $quotaCatalogContracts { + Test-Path $FullName | Should -BeTrue + $content = Get-Content -Path $FullName -Raw + + $content | Should -Match ([regex]::Escape("x_SourceType =~ '$SourceType'")) + $content | Should -Match '\| summarize arg_max\(x_IngestionTime, \*\) by ResourceId' + $content | Should -Not -Match 'PostgreSQL' + + foreach ($column in $quotaCatalogColumns) + { + $content | Should -Match "(?m)^\s+$column,?\r?$" -Because "quota catalog query '$Name' should project '$column'" + } + } + + It 'Should limit quota schemas to the approved public raw fields' { + $quotaRawFields = @( + 'ProviderName' + 'ResourceId' + 'ResourceName' + 'ResourceType' + 'SubAccountId' + 'displayName' + 'location' + 'currentValue' + 'limit' + 'unit' + 'x_QuotaDetails' + 'x_SourceName' + 'x_SourceProvider' + 'x_SourceType' + 'x_SourceVersion' + ) + + foreach ($schemaFile in ($schemaFiles | Where-Object { $_.AppName -eq 'Quota' })) + { + $schema = Get-Content -Path $schemaFile.FullName -Raw | ConvertFrom-Json + @($schema.translator.mappings | Where-Object { $_.sink.name -notin $quotaRawFields }).Count | Should -Be 0 + } + } + + It 'Should define the exact quota raw and final table boundaries' { + $rawColumns = [regex]::Match($rawTablesContent, '(?s)// Quota_raw table -- Redefine all columns\s+\.alter table Quota_raw \((.*?)\)\s+// Quota_raw ingestion mapping').Groups[1].Value + $finalColumns = [regex]::Match($ingestionSetupContent, '(?s)// Quota_final_v1_0 table\s+\.create-merge table Quota_final_v1_0 \((.*?)\)\s+// Update policy').Groups[1].Value + + [regex]::Matches($rawColumns, '(?m)^\s+\w+\s*:').Count | Should -Be 15 + [regex]::Matches($finalColumns, '(?m)^\s+\w+\s*:').Count | Should -Be 16 + $ingestionSetupContent | Should -Match 'Quota_transform_v1_0\(\)' + $ingestionSetupContent | Should -Match 'extent_tags\(\)' + $ingestionSetupContent | Should -Match 'bag_pack\(' + $ingestionSetupContent | Should -Match "x_SourceType !~ 'PremiumSSDv2Disk' or displayName =~ 'PremiumV2_LRS'" + $ingestionSetupContent | Should -Match "x_SourceType =~ 'AppServiceUsage'" + $ingestionSetupContent | Should -Match "x_SourceType =~ 'StorageUsage'" + $hubSetupContent | Should -Match 'Quota_v1_0\(\)' + $hubLatestContent | Should -Match 'Quota\(\)' + } + + It 'Should use the approved four-state deployment matrix' { + $mainTemplateContent | Should -Match 'param enableQuota bool = false' + $mainTemplateContent | Should -Match 'enableQuota: enableQuota' + $hubModuleContent | Should -Match "module ingestionQueries .+ = if \(enableRecommendations \|\| enableQuota\)" + $hubModuleContent | Should -Match "module azureResourceGraph .+ = if \(enableRecommendations\)" + $hubModuleContent | Should -Match "module azureResourceManager .+ = if \(enableRecommendations \|\| enableQuota\)" + $hubModuleContent | Should -Match "module recommendations .+ = if \(enableRecommendations\)" + $hubModuleContent | Should -Match "module quota .+ = if \(enableQuota\)" + } + + It 'Should expose one quota portal checkbox and output' { + $quotaControls = @($portal.parameters.steps | ForEach-Object { $_.elements } | Where-Object { $_.name -eq 'enableQuota' }) + $quotaControls.Count | Should -Be 1 + $portal.parameters.outputs.enableQuota | Should -Be "[steps('recommendations').enableQuota]" + } + + It 'Should describe Savings Plans and quota in the portal' { + $recommendationsStep = $portal.parameters.steps | Where-Object { $_.name -eq 'recommendations' } + $recommendationsStep.label | Should -Be 'Recommendations and quota' + ($recommendationsStep.elements | Where-Object { $_.name -eq 'included' }).elements.name | Should -Contain 'savingsPlans' + $quotaSection = $recommendationsStep.elements | Where-Object { $_.name -eq 'quota' } + $quotaSection.visible | Should -Be "[steps('recommendations').enableQuota]" + $quotaSection.elements.name | Should -Contain 'quotaDataTypes' + $quotaSection.elements.name | Should -Contain 'quotaAppService' + $quotaSection.elements.name | Should -Contain 'quotaStorage' + } + + It 'Should invoke one query generator function for the two approved apps' { + $buildScriptContent | Should -Match 'function Update-HubIngestionQueriesApp' + [regex]::Matches($buildScriptContent, "-AppName '(Recommendations|Quota)'").Count | Should -Be 2 + } + } + Context 'Bicep compilation' { It 'finops-hub template should compile without errors' { diff --git a/src/queries/INDEX.md b/src/queries/INDEX.md index e15e09e73..1d5653cfc 100644 --- a/src/queries/INDEX.md +++ b/src/queries/INDEX.md @@ -2,7 +2,7 @@ > **Note:** Refer to the [FinOps hub database documentation](./finops-hub-database-guide.md) for table and column definitions. -This catalog contains 37 scenario-specific FinOps Hub KQL queries used by the FinOps Toolkit agents and the Azure SRE Agent recipe. +This catalog contains 47 scenario-specific FinOps hub KQL queries used by the FinOps Toolkit agents and the Azure SRE Agent recipe. > **Tip:** Prefer the narrowest scenario-specific query that answers the question. Use [`costs-enriched-base`](./catalog/costs-enriched-base.kql) when you need an enriched row-level baseline for scoped custom analysis or repeated drill-downs. @@ -33,6 +33,16 @@ This catalog contains 37 scenario-specific FinOps Hub KQL queries used by the Fi | List top commitment (RI/SP) transactions | [top-commitment-transactions](./catalog/top-commitment-transactions.kql) | Identify the largest reservation or savings plan purchases and their impact. | Set `N` parameter (default: 10) for result count; set `startDate`/`endDate` to analyze specific purchase periods. | | List top other (non-commitment, non-usage) transactions | [top-other-transactions](./catalog/top-other-transactions.kql) | Analyze large purchases not covered by RI/SP (e.g., support, marketplace, etc.). | Set `N` parameter (default: 10) for result count; set `startDate`/`endDate` to analyze non-usage spend. | | Analyze reservation recommendations and break-even points | [reservation-recommendation-breakdown](./catalog/reservation-recommendation-breakdown.kql) | Review Microsoft recommendations for new reservations and their projected savings. | Run without parameters for all recommendations; filter by service or region for targeted optimization. | +| Analyze Savings Plan recommendations | [savings-plan-recommendation-breakdown](./catalog/savings-plan-recommendation-breakdown.kql) | Review Microsoft Savings Plan recommendations for all supported benefit types and terms. | Run without parameters to include compute and database recommendations for one-year and three-year terms. | +| Review current quota usage | [quota-current-usage](./catalog/quota-current-usage.kql) | Review the latest quota and capacity observations across subscriptions and regions. | Run without parameters; filter by subscription, region, resource type, or quota name for targeted analysis. | +| Identify quota headroom | [quota-headroom](./catalog/quota-headroom.kql) | Calculate remaining headroom and usage percentage for quota records with nonnegative limits. | Run without parameters; filter on `PercentUsed` or `Headroom` to prioritize capacity reviews. | +| Review App Service quota usage | [quota-app-service-usage](./catalog/quota-app-service-usage.kql) | Review the latest App Service quota usage for each subscription and region. | Run without parameters; filter by subscription, region, or quota name for targeted analysis. | +| Review Azure AI services quota usage | [quota-cognitive-services-usage](./catalog/quota-cognitive-services-usage.kql) | Review the latest Azure AI services quota usage for each subscription and region. | Run without parameters; filter by subscription, region, or quota name for targeted analysis. | +| Review capacity reservation inventory | [quota-capacity-reservations](./catalog/quota-capacity-reservations.kql) | Review the latest capacity reservation group inventory for each subscription. | Run without parameters; do not calculate quota headroom because inventory rows can have empty limits. | +| Review compute quota usage | [quota-compute-usage](./catalog/quota-compute-usage.kql) | Review the latest compute quota usage for each subscription and region. | Run without parameters; filter by subscription, region, or quota name for targeted analysis. | +| Review Premium SSD v2 disk inventory | [quota-premium-ssd-v2-disks](./catalog/quota-premium-ssd-v2-disks.kql) | Review the latest Premium SSD v2 disk inventory for each subscription. | Run without parameters; `currentValue` contains disk size in GiB and `limit` can be empty. | +| Review SQL subscription quota usage | [quota-sql-subscription-usage](./catalog/quota-sql-subscription-usage.kql) | Review the latest Azure SQL quota usage for each subscription and region. | Run without parameters; filter by subscription, region, or quota name for targeted analysis. | +| Review Storage quota usage | [quota-storage-usage](./catalog/quota-storage-usage.kql) | Review the latest Storage quota usage for each subscription and region. | Run without parameters; filter by subscription, region, or quota name for targeted analysis. | | Measure % of cost on resources with no tags (KPI: Untagged Costs) | [percentage-untagged-costs](./catalog/percentage-untagged-costs.kql) | FinOps Foundation KPI quantifying cost from resources that carry zero tags. | Set `startDate`/`endDate` for the KPI window; output column `UntaggedPercent` is ready for dashboards and SLO tracking. | | Measure % of cost on resources lacking required allocation evidence (KPI: Unallocated Costs) | [percentage-unallocated-costs](./catalog/percentage-unallocated-costs.kql) | FinOps Foundation KPI quantifying cost that cannot be allocated due to missing rule, cost center, or ownership-tag evidence. | Edit `allocationEvidenceTagKeys` to your allocation policy; pair with `allocation-accuracy-index` for the complement view. | | Measure Allocation Accuracy Index (AAI) | [allocation-accuracy-index](./catalog/allocation-accuracy-index.kql) | Directly attributed effective cost expressed as a 0-100 percentage — FinOps Foundation KPI. | Edit `allocationEvidenceTagKeys` to your allocation policy; use the same evidence set as `percentage-unallocated-costs` for a comparable view. | diff --git a/src/queries/catalog/quota-app-service-usage.kql b/src/queries/catalog/quota-app-service-usage.kql new file mode 100644 index 000000000..17db97eba --- /dev/null +++ b/src/queries/catalog/quota-app-service-usage.kql @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: App Service quota usage +// DESCRIPTION: Rehydrates the latest App Service quota usage records for each subscription and region. +// CATEGORY: Governance +// FEATURE: Quota +// REGION: All +// TECH: KQL +// +// RESOURCE TYPES: Microsoft.Web/locations/usages +// +// SOURCE: Quota() +// + +Quota() +| where x_SourceType =~ 'AppServiceUsage' +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_QuotaDetails, + x_SourceType, + x_SourceVersion, + x_IngestionTime +| order by SubAccountId asc, location asc, ResourceName asc diff --git a/src/queries/catalog/quota-capacity-reservations.kql b/src/queries/catalog/quota-capacity-reservations.kql new file mode 100644 index 000000000..c59632a72 --- /dev/null +++ b/src/queries/catalog/quota-capacity-reservations.kql @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: Capacity reservations +// DESCRIPTION: Rehydrates the latest capacity reservation group inventory for each subscription. +// CATEGORY: Governance +// FEATURE: Quota +// REGION: All +// TECH: KQL +// +// RESOURCE TYPES: Microsoft.Compute/capacityReservationGroups +// +// SOURCE: Quota() +// +// NOTES: +// - Capacity reservation groups are inventory observations, so usage, limit, and unit may be empty. +// + +Quota() +| where x_SourceType =~ 'CapacityReservation' +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_QuotaDetails, + x_SourceType, + x_SourceVersion, + x_IngestionTime +| order by SubAccountId asc, location asc, ResourceName asc diff --git a/src/queries/catalog/quota-cognitive-services-usage.kql b/src/queries/catalog/quota-cognitive-services-usage.kql new file mode 100644 index 000000000..ca5d2d1bf --- /dev/null +++ b/src/queries/catalog/quota-cognitive-services-usage.kql @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: Azure AI services quota usage +// DESCRIPTION: Rehydrates the latest Azure AI services quota usage records for each subscription and region. +// CATEGORY: Governance +// FEATURE: Quota +// REGION: All +// TECH: KQL +// +// RESOURCE TYPES: Microsoft.CognitiveServices/locations/usages +// +// SOURCE: Quota() +// + +Quota() +| where x_SourceType =~ 'CognitiveServicesUsage' +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_QuotaDetails, + x_SourceType, + x_SourceVersion, + x_IngestionTime +| order by SubAccountId asc, location asc, ResourceName asc diff --git a/src/queries/catalog/quota-compute-usage.kql b/src/queries/catalog/quota-compute-usage.kql new file mode 100644 index 000000000..81c784e65 --- /dev/null +++ b/src/queries/catalog/quota-compute-usage.kql @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: Compute quota usage +// DESCRIPTION: Rehydrates the latest compute quota usage records for each subscription and region. +// CATEGORY: Governance +// FEATURE: Quota +// REGION: All +// TECH: KQL +// +// RESOURCE TYPES: Microsoft.Compute/locations/usages +// +// SOURCE: Quota() +// + +Quota() +| where x_SourceType =~ 'ComputeUsage' +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_QuotaDetails, + x_SourceType, + x_SourceVersion, + x_IngestionTime +| order by SubAccountId asc, location asc, ResourceName asc diff --git a/src/queries/catalog/quota-current-usage.kql b/src/queries/catalog/quota-current-usage.kql new file mode 100644 index 000000000..c7a29d3a3 --- /dev/null +++ b/src/queries/catalog/quota-current-usage.kql @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: Current quota usage +// DESCRIPTION: Shows the latest quota and capacity observations across subscriptions and regions. +// CATEGORY: Governance +// FEATURE: Quota +// REGION: All +// TECH: KQL +// +// SOURCE: Quota() +// + +Quota() +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_SourceType, + x_SourceVersion, + x_IngestionTime +| order by SubAccountId asc, location asc, ResourceType asc, ResourceName asc diff --git a/src/queries/catalog/quota-headroom.kql b/src/queries/catalog/quota-headroom.kql new file mode 100644 index 000000000..d1f52076a --- /dev/null +++ b/src/queries/catalog/quota-headroom.kql @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: Quota headroom +// DESCRIPTION: Calculates remaining headroom and usage percentage for quota records with nonnegative limits. +// CATEGORY: Governance +// FEATURE: Quota +// REGION: All +// TECH: KQL +// +// SOURCE: Quota() +// + +Quota() +| summarize arg_max(x_IngestionTime, *) by ResourceId +| extend + Headroom = iff(limit >= 0, limit - currentValue, real(null)), + PercentUsed = iff(limit > 0, round(100.0 * currentValue / limit, 1), real(null)) +| project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + Headroom, + PercentUsed, + unit, + x_SourceType, + x_IngestionTime +| order by PercentUsed desc nulls last diff --git a/src/queries/catalog/quota-premium-ssd-v2-disks.kql b/src/queries/catalog/quota-premium-ssd-v2-disks.kql new file mode 100644 index 000000000..eb6b6e516 --- /dev/null +++ b/src/queries/catalog/quota-premium-ssd-v2-disks.kql @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: Premium SSD v2 disks +// DESCRIPTION: Rehydrates the latest Premium SSD v2 disk inventory for each subscription. +// CATEGORY: Governance +// FEATURE: Quota +// REGION: All +// TECH: KQL +// +// RESOURCE TYPES: Microsoft.Compute/disks +// +// SOURCE: Quota() +// +// NOTES: +// - Premium SSD v2 disks are inventory observations. currentValue contains disk size in GiB, and limit may be empty. +// + +Quota() +| where x_SourceType =~ 'PremiumSSDv2Disk' +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_QuotaDetails, + x_SourceType, + x_SourceVersion, + x_IngestionTime +| order by SubAccountId asc, location asc, ResourceName asc diff --git a/src/queries/catalog/quota-sql-subscription-usage.kql b/src/queries/catalog/quota-sql-subscription-usage.kql new file mode 100644 index 000000000..1d3500a8e --- /dev/null +++ b/src/queries/catalog/quota-sql-subscription-usage.kql @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: SQL subscription quota usage +// DESCRIPTION: Rehydrates the latest Azure SQL quota usage records for each subscription and region. +// CATEGORY: Governance +// FEATURE: Quota +// REGION: All +// TECH: KQL +// +// RESOURCE TYPES: Microsoft.Sql/locations/usages +// +// SOURCE: Quota() +// + +Quota() +| where x_SourceType =~ 'SqlSubscriptionUsage' +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_QuotaDetails, + x_SourceType, + x_SourceVersion, + x_IngestionTime +| order by SubAccountId asc, location asc, ResourceName asc diff --git a/src/queries/catalog/quota-storage-usage.kql b/src/queries/catalog/quota-storage-usage.kql new file mode 100644 index 000000000..443777a9d --- /dev/null +++ b/src/queries/catalog/quota-storage-usage.kql @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: Storage quota usage +// DESCRIPTION: Rehydrates the latest Storage quota usage records for each subscription and region. +// CATEGORY: Governance +// FEATURE: Quota +// REGION: All +// TECH: KQL +// +// RESOURCE TYPES: Microsoft.Storage/locations/usages +// +// SOURCE: Quota() +// + +Quota() +| where x_SourceType =~ 'StorageUsage' +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_QuotaDetails, + x_SourceType, + x_SourceVersion, + x_IngestionTime +| order by SubAccountId asc, location asc, ResourceName asc diff --git a/src/queries/catalog/savings-plan-recommendation-breakdown.kql b/src/queries/catalog/savings-plan-recommendation-breakdown.kql new file mode 100644 index 000000000..f29203135 --- /dev/null +++ b/src/queries/catalog/savings-plan-recommendation-breakdown.kql @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// NAME: Savings plan recommendation breakdown +// DESCRIPTION: Summarizes Savings Plan recommendations for all supported benefit types and terms. +// CATEGORY: Optimization +// FEATURE: Recommendations +// REGION: All +// TECH: KQL +// +// RESOURCE TYPES: Microsoft.CostManagement/benefitRecommendations +// +// SOURCE: Recommendations() +// +// NOTES: +// - Savings Plan recommendations include both compute and database benefits. +// - Costs and savings use the billing currency returned by Cost Management. +// - Savings rates are rounded to one decimal place for readability. +// + +Recommendations() +| where x_SourceProvider =~ "Microsoft" and x_SourceType =~ "SavingsPlan" +| extend + BenefitType = replace_regex(tostring(x_RecommendationDetails.x_ArmSkuName), @"_Savings_Plan$", ""), + ArmSkuName = tostring(x_RecommendationDetails.x_ArmSkuName), + Term = tostring(x_RecommendationDetails.x_Term), + LookBackPeriod = tostring(x_RecommendationDetails.x_LookBackPeriod), + Scope = tostring(x_RecommendationDetails.x_Scope), + CurrencyCode = tostring(x_RecommendationDetails.x_CurrencyCode) +| summarize arg_max(x_RecommendationDate, *) by BenefitType, ArmSkuName, Term, LookBackPeriod, Scope +| project + BenefitType, + ArmSkuName, + Term, + LookBackPeriod, + Scope, + CurrencyCode, + CostWithoutBenefit = x_EffectiveCostBefore, + CostWithBenefit = x_EffectiveCostAfter, + EstimatedSavings = x_EffectiveCostSavings, + SavingsRate = round(100.0 * x_EffectiveCostSavings / x_EffectiveCostBefore, 1), + RecommendationDate = x_RecommendationDate, + RecommendationId = x_RecommendationId +| order by EstimatedSavings desc diff --git a/src/queries/finops-hub-database-guide.md b/src/queries/finops-hub-database-guide.md index 34e80464a..79e04fcc8 100644 --- a/src/queries/finops-hub-database-guide.md +++ b/src/queries/finops-hub-database-guide.md @@ -26,6 +26,7 @@ This document provides a comprehensive overview of how to query and analyze data - [Table reference](#table-reference) - [Costs()](#costs) - [Prices()](#prices) + - [Quota()](#quota) - [Recommendations()](#recommendations) - [Transactions()](#transactions) - [Glossary](#glossary) @@ -612,6 +613,55 @@ The following table lists the columns produced in the `All available columns` qu --- +### Quota() + +The `Quota()` function returns normalized quota usage and resource inventory observations from configured FinOps hub queries. A resource can have more than one snapshot. Select the newest `x_IngestionTime` for each `ResourceId` before you use the data as current state. + +| Column Name | Data Type | Description | +|-------------|-----------|-------------| +| `ProviderName` | string | Name of the cloud provider. | +| `ResourceId` | string | Canonical key for the quota or inventory object. Some usage sources use a synthetic resource ID. | +| `ResourceName` | string | Provider-defined quota or resource name. | +| `ResourceType` | string | Azure resource type or usage endpoint type. | +| `SubAccountId` | string | Subscription resource ID that the query used. | +| `displayName` | string | Provider-defined display name. | +| `location` | string | Azure region for the observation. | +| `currentValue` | real | Current usage value. For Premium SSD v2 disks, this value is the disk size in GiB. | +| `limit` | real | Quota limit when the source supplies one. Inventory sources can leave this value empty. | +| `unit` | string | Unit for `currentValue` and `limit` when the source supplies one. | +| `x_QuotaDetails` | dynamic | Source details and normalized query metadata. | +| `x_SourceName` | string | Source name recorded by the ingestion process. | +| `x_SourceProvider` | string | Source provider recorded by the ingestion process. | +| `x_SourceType` | string | Source object type. Use this column to select one quota dataset. | +| `x_SourceVersion` | string | Version of the source contract. | +| `x_IngestionTime` | datetime | Time when the snapshot entered the normalized table. | + +#### Supported source types + +| `x_SourceType` | Source version | Observation | Canonical key | +|----------------|----------------|-------------|---------------| +| `AppServiceUsage` | `1.0-usage` | App Service regional quota usage | Synthetic `ResourceId` | +| `CapacityReservation` | `1.0-capacity-reservation` | Capacity reservation group inventory | Native ARM `ResourceId` | +| `CognitiveServicesUsage` | `1.0-usage` | Azure AI services regional quota usage | Synthetic `ResourceId` | +| `ComputeUsage` | `1.0-usage` | Compute regional quota usage | Synthetic `ResourceId` | +| `PremiumSSDv2Disk` | `1.0-disk` | Premium SSD v2 disk inventory | Native ARM `ResourceId` | +| `SqlSubscriptionUsage` | `1.0-sql` | Azure SQL regional quota usage | Native ARM `ResourceId` | +| `StorageUsage` | `1.0-usage` | Storage regional quota usage | Synthetic `ResourceId` | + +Use this pattern to return one current row for each object: + +```kusto +Quota() +| where x_SourceType =~ 'ComputeUsage' +| summarize arg_max(x_IngestionTime, *) by ResourceId +``` + +Capacity reservations and Premium SSD v2 disks are inventory observations. Their `limit` and `unit` values can be empty. Do not calculate quota headroom or percentage used for these rows. For Premium SSD v2 disks, `currentValue` contains the disk size in GiB. + +PostgreSQL quota is not supported. Its regional endpoint does not match the current query pipeline contract. + +--- + ### Recommendations() > **Sparsely-populated columns:** @@ -716,6 +766,7 @@ The following table lists the columns produced in the `All available columns` qu | 2025-05-16 | 1.1 | FinOps Toolkit Team | Expanded schema, glossary, references | | 2026-05-28 | 1.2 | Sprint 3000 UAT | Live-Hub schema audit: `Costs()`, `Prices()`, `Recommendations()` numeric columns retyped from `decimal` to `real` to match deployed Hub schema (cause of SEM0019 errors). `Recommendations()` table expanded from 12 to 20 columns to add the 8 columns present in the live schema. `x_RecommendationDate` documented as commonly-null in live Hubs (root cause of T-3000.13). | | 2026-08-04 | 1.3 | FinOps Toolkit Team | Added KQL language rules (case-insensitive operators, explicit join kinds, `lookup` for dimension enrichment) distilled from the project coding guidelines so query-writing agents load them alongside the schema. | +| 2026-08-23 | 1.4 | FinOps Toolkit Team | Added the `Quota()` reference, source-type matrix, latest-state guidance, and inventory-source caveats. | --- diff --git a/src/scripts/Build-HubIngestionQueries.ps1 b/src/scripts/Build-HubIngestionQueries.ps1 index 89738a2b5..df3113e96 100644 --- a/src/scripts/Build-HubIngestionQueries.ps1 +++ b/src/scripts/Build-HubIngestionQueries.ps1 @@ -6,9 +6,10 @@ Generates Bicep loadTextContent entries for ingestion query files. .DESCRIPTION - Scans query JSON files in the Recommendations app and generates the corresponding - Bicep variable blocks in app.bicep. Each query file specifies an opt-in group via - an optional "group" field. Files without a group are added to the core set. + Scans query JSON files in the Recommendations and Quota apps and generates the + corresponding Bicep variable blocks in each app.bicep. Each query file specifies + an opt-in group via an optional "group" field. Files without a group are added to + the core set. This script runs as a post-copy build step, modifying the release copy of app.bicep rather than the source files. The source app.bicep contains placeholder markers that @@ -20,7 +21,8 @@ .EXAMPLE ./Build-HubIngestionQueries.ps1 -DestDir ./release/finops-hub - Regenerates the loadTextContent entries in the release copy of Recommendations/app.bicep. + Regenerates the loadTextContent entries in the release copies of the Recommendations + and Quota app.bicep files. .LINK https://github.com/microsoft/finops-toolkit/blob/dev/src/scripts/README.md @@ -31,8 +33,16 @@ param( [Parameter(Mandatory)][string]$DestDir ) -$queriesPath = Join-Path $DestDir 'modules/Microsoft.FinOpsHubs/Recommendations/queries' -$appBicepPath = Join-Path $DestDir 'modules/Microsoft.FinOpsHubs/Recommendations/app.bicep' +function Update-HubIngestionQueriesApp +{ + param( + [Parameter(Mandatory)][string]$AppName, + [Parameter(Mandatory)][hashtable]$GroupConfig, + [Parameter(Mandatory)][string[]]$GroupOrder + ) + +$queriesPath = Join-Path $DestDir "modules/Microsoft.FinOpsHubs/$AppName/queries" +$appBicepPath = Join-Path $DestDir "modules/Microsoft.FinOpsHubs/$AppName/app.bicep" if (-not (Test-Path $queriesPath)) { @@ -42,7 +52,7 @@ if (-not (Test-Path $queriesPath)) if (-not (Test-Path $appBicepPath)) { - Write-Warning "Recommendations app.bicep not found at $appBicepPath; skipping" + Write-Warning "$AppName app.bicep not found at $appBicepPath; skipping" return } @@ -91,13 +101,6 @@ function Format-BicepVar($varName, $files, $conditional) return $lines -join "`n" } -# Known group-to-variable mappings with their conditional expressions -$groupConfig = @{ - 'core' = @{ VarName = 'coreQueryFiles'; Conditional = $null } - 'ahb' = @{ VarName = 'ahbQueryFiles'; Conditional = 'enableAHBRecommendations ?' } - 'spot' = @{ VarName = 'spotQueryFiles'; Conditional = 'enableSpotRecommendations ?' } -} - # Build the generated block $startMarker = '// ' $endMarker = '// ' @@ -105,44 +108,40 @@ $endMarker = '// ' $generatedLines = @($startMarker) $varNames = @() -foreach ($groupName in @('core', 'ahb', 'spot')) +foreach ($groupName in $GroupOrder) { if (-not $groups.ContainsKey($groupName)) { continue } - $config = $groupConfig[$groupName] + $config = $GroupConfig[$groupName] $varNames += $config.VarName if ($generatedLines.Count -gt 1) { $generatedLines += '' } - # Add comment for non-core groups - switch ($groupName) + switch ("$AppName/$groupName") { - 'core' { $generatedLines += '// Load query files -- core recommendations are always included' } - 'ahb' { $generatedLines += '// Optional: Azure Hybrid Benefit recommendations (may generate noise without on-premises licenses)' } - 'spot' { $generatedLines += '// Optional: Spot VM recommendations (may generate noise for non-interruptible workloads)' } + 'Recommendations/core' { $generatedLines += '// Load query files -- core recommendations are always included' } + 'Recommendations/ahb' { $generatedLines += '// Optional: Azure Hybrid Benefit recommendations (may generate noise without on-premises licenses)' } + 'Recommendations/spot' { $generatedLines += '// Optional: Spot VM recommendations (may generate noise for non-interruptible workloads)' } + 'Quota/core' { $generatedLines += '// Load query files -- quota queries are always included' } } $generatedLines += Format-BicepVar $config.VarName $groups[$groupName] $config.Conditional } -# Handle any unknown groups foreach ($groupName in ($groups.Keys | Sort-Object)) { - if ($groupConfig.ContainsKey($groupName)) { continue } - - Write-Warning "Unknown query group '$groupName' found; adding as opt-in variable" - $varName = "${groupName}QueryFiles" - $paramName = "enable$($groupName.Substring(0,1).ToUpper())$($groupName.Substring(1))Recommendations" - $varNames += $varName - - $generatedLines += '' - $generatedLines += "// Optional: $groupName recommendations" - $generatedLines += Format-BicepVar $varName $groups[$groupName] "$paramName ?" + if ($GroupConfig.ContainsKey($groupName)) { continue } + throw "Unknown query group '$groupName' in $AppName. Expected groups: $($GroupOrder -join ', ')" } # Add the union line $generatedLines += '' -$generatedLines += "var queryFiles = union($($varNames -join ', '))" +$generatedLines += if ($varNames.Count -eq 1) { + "var queryFiles = $($varNames[0])" +} +else { + "var queryFiles = union($($varNames -join ', '))" +} $generatedLines += $endMarker $generatedBlock = $generatedLines -join "`n" @@ -170,3 +169,20 @@ else Write-Warning "Could not find generated section markers in app.bicep; manual update required" Write-Warning "Expected markers: $startMarker ... $endMarker" } +} + +Update-HubIngestionQueriesApp ` + -AppName 'Recommendations' ` + -GroupConfig @{ + 'core' = @{ VarName = 'coreQueryFiles'; Conditional = $null } + 'ahb' = @{ VarName = 'ahbQueryFiles'; Conditional = 'enableAHBRecommendations ?' } + 'spot' = @{ VarName = 'spotQueryFiles'; Conditional = 'enableSpotRecommendations ?' } + } ` + -GroupOrder @('core', 'ahb', 'spot') + +Update-HubIngestionQueriesApp ` + -AppName 'Quota' ` + -GroupConfig @{ + 'core' = @{ VarName = 'coreQueryFiles'; Conditional = $null } + } ` + -GroupOrder @('core') diff --git a/src/templates/finops-hub/createUiDefinition.json b/src/templates/finops-hub/createUiDefinition.json index 52f5ae4e2..2ed3c75e5 100644 --- a/src/templates/finops-hub/createUiDefinition.json +++ b/src/templates/finops-hub/createUiDefinition.json @@ -703,20 +703,27 @@ }, { "name": "recommendations", - "label": "🆕 Recommendations", + "label": "Recommendations and quota", "elements": [ { "name": "recommendationsIntro", "type": "Microsoft.Common.TextBlock", "visible": true, "options": { - "text": "Uncover hidden savings with FinOps hubs recommendations. FinOps hubs can automatically scan your environment using Azure Resource Graph to surface cost optimization opportunities like idle resources and missed discounts that aren't available in Microsoft Cost Management or Azure Advisor." + "text": "Scan your environment for cost optimization recommendations, quota usage, and available capacity." } }, { "name": "enableRecommendations", "type": "Microsoft.Common.CheckBox", - "label": "Enable hubs recommendations (preview)" + "label": "Enable hubs recommendations (preview)", + "toolTip": "Scan Azure Resource Graph and Azure Resource Manager for cost optimization recommendations, including Azure savings plan recommendations." + }, + { + "name": "enableQuota", + "type": "Microsoft.Common.CheckBox", + "label": "Enable quota and capacity ingestion (preview)", + "toolTip": "Collect compute, AI service, SQL, disk, and capacity reservation usage and capacity data from Azure Resource Manager." }, { "name": "included", @@ -740,6 +747,14 @@ "text": "✅ Azure Advisor cost recommendations" } }, + { + "name": "savingsPlans", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ 1-year and 3-year Azure savings plan recommendations" + } + }, { "name": "stoppedVMs", "type": "Microsoft.Common.TextBlock", @@ -818,18 +833,90 @@ } ] }, + { + "name": "quota", + "type": "Microsoft.Common.Section", + "label": "Quota and capacity data", + "visible": "[steps('recommendations').enableQuota]", + "elements": [ + { + "name": "quotaIntro", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "Collect current usage, limits, and available capacity for:" + } + }, + { + "name": "quotaDataTypes", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Compute quota" + } + }, + { + "name": "quotaAzureAI", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Azure AI service quota" + } + }, + { + "name": "quotaAzureSQL", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Azure SQL quota" + } + }, + { + "name": "quotaAppService", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Azure App Service quota" + } + }, + { + "name": "quotaPremiumSSDv2", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Premium SSD v2 disks" + } + }, + { + "name": "quotaStorage", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Azure Storage account quota" + } + }, + { + "name": "quotaCapacityReservations", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "✅ Capacity reservations" + } + } + ] + }, { "name": "permissions", "type": "Microsoft.Common.Section", "label": "Required permissions", - "visible": "[steps('recommendations').enableRecommendations]", + "visible": "[or(steps('recommendations').enableRecommendations, steps('recommendations').enableQuota)]", "elements": [ { "name": "permissionsNote", "type": "Microsoft.Common.TextBlock", "visible": true, "options": { - "text": "The Data Factory managed identity requires Reader role on the management groups or subscriptions you want to scan. After deployment, grant the hub's managed identity Reader access to the desired scopes." + "text": "The Data Factory managed identity requires the Reader role on the management groups or subscriptions to scan. After deployment, grant the hub's managed identity Reader access to each scope." } } ] @@ -1000,6 +1087,7 @@ "enableInfrastructureEncryption": "[steps('advanced').storage.enableInfrastructureEncryption]", "enableManagedExports": "[steps('advanced').managedExports.enableManagedExports]", "enableRecommendations": "[steps('recommendations').enableRecommendations]", + "enableQuota": "[steps('recommendations').enableQuota]", "enableAHBRecommendations": "[steps('recommendations').optional.enableAHBRecommendations]", "enableSpotRecommendations": "[steps('recommendations').optional.enableSpotRecommendations]", "enablePublicAccess": "[steps('advanced').networking.enablePublicAccess]", diff --git a/src/templates/finops-hub/main.bicep b/src/templates/finops-hub/main.bicep index d93fe98c4..d02a8107a 100644 --- a/src/templates/finops-hub/main.bicep +++ b/src/templates/finops-hub/main.bicep @@ -42,6 +42,9 @@ param enableManagedExports bool = true @description('Optional. Enable recommendations ingested from Azure Resource Graph based on configurable queries. The Data Factory managed identity requires Reader role on management groups or subscriptions to execute Resource Graph queries. Default: false.') param enableRecommendations bool = false +@description('Optional. Enable quota and capacity data ingestion from Azure Resource Manager. The Data Factory managed identity requires Reader role on the subscriptions to scan. Default: false.') +param enableQuota bool = false + @description('Optional. Enable Azure Hybrid Benefit recommendations that flag VMs and SQL VMs without Azure Hybrid Benefit enabled. May generate noise if your organization does not have on-premises licenses. Requires enableRecommendations. Default: false.') param enableAHBRecommendations bool = false @@ -181,6 +184,7 @@ module hub 'modules/hub.bicep' = { enablePurgeProtection: enablePurgeProtection enableManagedExports: enableManagedExports enableRecommendations: enableRecommendations + enableQuota: enableQuota enableAHBRecommendations: enableAHBRecommendations enableSpotRecommendations: enableSpotRecommendations dataExplorerName: dataExplorerName diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_Latest.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_Latest.kql index 8de86a1aa..7f7699c25 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_Latest.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_Latest.kql @@ -41,6 +41,14 @@ Recommendations() } +.create-or-alter function +with (docstring = 'Gets all quota records with the latest supported schema.', folder = 'Quota') +Quota() +{ + Quota_v1_0() +} + + .create-or-alter function with (docstring = 'Gets all transactions with the latest supported version of the FOCUS schema.', folder = 'Transactions') Transactions() diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_0.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_0.kql index 2507e9fbf..7e48a6b1d 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_0.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/HubSetup_v1_0.kql @@ -331,6 +331,32 @@ Recommendations_v1_0() } +// Quota_final_v1_0 +.create-or-alter function +with (docstring = 'Gets all quota records aligned to the Quota 1.0 contract.', folder = 'Quota') +Quota_v1_0() +{ + database('Ingestion').Quota_final_v1_0 + | project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_QuotaDetails, + x_SourceName, + x_SourceProvider, + x_SourceType, + x_SourceVersion, + x_IngestionTime +} + + // Transactions_final_v1_0 .create-or-alter function with (docstring = 'Gets all transactions aligned to FOCUS 1.0.', folder = 'Transactions') diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_RawTables.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_RawTables.kql index 94fead987..3c25952cd 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_RawTables.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_RawTables.kql @@ -888,6 +888,68 @@ .alter table Recommendations_raw policy streamingingestion disable +//===| Quota |========================================================================================================== +// Supported versions: +// - Microsoft Azure Resource Manager: 1.0 +//====================================================================================================================== + +// Quota_raw table -- Create the table if it doesn't exist +.create-merge table Quota_raw ( ignore: string ) + +// Quota_raw table -- Remove all columns to allow changing column types +.alter table Quota_raw ( ignore: string ) + +// Quota_raw table -- Redefine all columns +.alter table Quota_raw ( + ProviderName: string, + ResourceId: string, + ResourceName: string, + ResourceType: string, + SubAccountId: string, + displayName: string, + location: string, + currentValue: real, + limit: real, + unit: string, + x_QuotaDetails: dynamic, + x_SourceName: string, + x_SourceProvider:string, + x_SourceType: string, + x_SourceVersion: string +) + +// Quota_raw ingestion mapping +.create-or-alter table Quota_raw ingestion parquet mapping "Quota_raw_mapping" +``` +[ + { "Column": "ProviderName", "Properties": { "Field": "ProviderName" } }, + { "Column": "ResourceId", "Properties": { "Field": "ResourceId" } }, + { "Column": "ResourceName", "Properties": { "Field": "ResourceName" } }, + { "Column": "ResourceType", "Properties": { "Field": "ResourceType" } }, + { "Column": "SubAccountId", "Properties": { "Field": "SubAccountId" } }, + { "Column": "displayName", "Properties": { "Field": "displayName" } }, + { "Column": "location", "Properties": { "Field": "location" } }, + { "Column": "currentValue", "Properties": { "Field": "currentValue" } }, + { "Column": "limit", "Properties": { "Field": "limit" } }, + { "Column": "unit", "Properties": { "Field": "unit" } }, + { "Column": "x_QuotaDetails", "Properties": { "Field": "x_QuotaDetails" } }, + { "Column": "x_SourceName", "Properties": { "Field": "x_SourceName" } }, + { "Column": "x_SourceProvider", "Properties": { "Field": "x_SourceProvider" } }, + { "Column": "x_SourceType", "Properties": { "Field": "x_SourceType" } }, + { "Column": "x_SourceVersion", "Properties": { "Field": "x_SourceVersion" } } +] +``` + +// Quota_raw retention policy (clear historical data) +.alter-merge table Quota_raw policy retention softdelete = 0d recoverability = disabled + +// Quota_raw retention policy (set the user-defined retention period) +.alter-merge table Quota_raw policy retention softdelete = $$rawRetentionInDays$$d recoverability = disabled + +// Disable Quota_raw streaming ingestion (required for Fabric) +.alter table Quota_raw policy streamingingestion disable + + //===| Transactions |=================================================================================================== // Supported versions: // - MS CM EA reservation transactions: 2023-05-01 -- See https://learn.microsoft.com/azure/cost-management-billing/dataset-schema/reservation-transactions-ea @@ -985,4 +1047,3 @@ // Disable Transactions_raw streaming ingestion (required for Fabric) .alter table Transactions_raw policy streamingingestion disable - diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql index 50401ebb7..9acbf5103 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_0.kql @@ -1490,6 +1490,115 @@ Recommendations_transform_v1_0() ``` +//===| Quota |========================================================================================================== +// Supported versions: +// - Microsoft Azure Resource Manager: 1.0 +//====================================================================================================================== + +// Quota_transform_v1_0 function +.create-or-alter function +with (docstring='Transforms Quota_raw into the Quota 1.0 contract.', folder='Quota') +Quota_transform_v1_0() +{ + Quota_raw + | extend x_IngestionTime = ingestion_time() + | extend tmp_SourcePath = extract(@'"drop-by:(Quota/[^"]+)"', 1, tostring(extent_tags())) + | extend tmp_OriginalFileName = tostring(split(tmp_SourcePath, '/')[-1]) + | extend tmp_FileParts = split(tmp_OriginalFileName, '--') + | extend + tmp_QueryType = tostring(tmp_FileParts[0]), + tmp_QueryVersion = tostring(tmp_FileParts[1]), + tmp_QueryScope = replace_string(tostring(tmp_FileParts[2]), '_', '/'), + tmp_QueryLocation = replace_string(tostring(tmp_FileParts[3]), '.parquet', '') + | extend + ProviderName = coalesce(ProviderName, 'Microsoft'), + SubAccountId = tolower(coalesce(SubAccountId, tmp_QueryScope)), + displayName = coalesce(displayName, ResourceName), + location = coalesce(location, tmp_QueryLocation), + x_SourceName = coalesce(x_SourceName, 'Azure Resource Manager'), + x_SourceProvider = coalesce(x_SourceProvider, 'Microsoft'), + x_SourceType = coalesce(x_SourceType, tmp_QueryType), + x_SourceVersion = coalesce(x_SourceVersion, tmp_QueryVersion) + | where x_SourceType !~ 'PremiumSSDv2Disk' or displayName =~ 'PremiumV2_LRS' + | extend ResourceType = coalesce(ResourceType, case( + x_SourceType =~ 'AppServiceUsage', 'Microsoft.Web/locations/usages', + x_SourceType =~ 'ComputeUsage', 'Microsoft.Compute/locations/usages', + x_SourceType =~ 'CognitiveServicesUsage', 'Microsoft.CognitiveServices/locations/usages', + x_SourceType =~ 'CapacityReservation', 'Microsoft.Compute/capacityReservationGroups', + x_SourceType =~ 'PremiumSSDv2Disk', 'Microsoft.Compute/disks', + x_SourceType =~ 'StorageUsage', 'Microsoft.Storage/locations/usages', + '' + )) + | extend ResourceId = tolower(coalesce(ResourceId, case( + x_SourceType in~ ('AppServiceUsage', 'ComputeUsage', 'CognitiveServicesUsage', 'StorageUsage'), + strcat(SubAccountId, '/providers/', tostring(split(ResourceType, '/')[0]), '/locations/', location, '/usages/', ResourceName), + '' + ))) + | extend x_QuotaDetails = bag_merge( + coalesce(x_QuotaDetails, dynamic({})), + bag_pack( + 'DisplayName', displayName, + 'Location', location, + 'CurrentValue', currentValue, + 'Limit', limit, + 'Unit', unit, + 'QueryScope', SubAccountId, + 'QueryType', x_SourceType, + 'QueryVersion', x_SourceVersion + ) + ) + | project + ProviderName, + ResourceId, + ResourceName, + ResourceType, + SubAccountId, + displayName, + location, + currentValue, + limit, + unit, + x_QuotaDetails, + x_SourceName, + x_SourceProvider, + x_SourceType, + x_SourceVersion, + x_IngestionTime +} + +// Quota_final_v1_0 table +.create-merge table Quota_final_v1_0 ( + ProviderName: string, + ResourceId: string, + ResourceName: string, + ResourceType: string, + SubAccountId: string, + displayName: string, + location: string, + currentValue: real, + limit: real, + unit: string, + x_QuotaDetails: dynamic, + x_SourceName: string, + x_SourceProvider: string, + x_SourceType: string, + x_SourceVersion: string, + x_IngestionTime: datetime +) + +// Update policy for Quota_raw -> Quota_final_v1_0 table +.alter table Quota_final_v1_0 policy update +``` +[{ + "IsEnabled": true, + "Source": "Quota_raw", + "Query": "Quota_transform_v1_0()", + "IsTransactional": true, + "PropagateIngestionProperties": true +}] +``` + + //===| Transactions |=================================================================================================== // Supported versions: // - MS CM EA reservation transactions: 2023-05-01 -- See https://learn.microsoft.com/azure/cost-management-billing/dataset-schema/reservation-transactions-ea diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_2.kql b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_2.kql index da41cafa4..784929e03 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_2.kql +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Analytics/scripts/IngestionSetup_v1_2.kql @@ -1679,7 +1679,11 @@ Recommendations_transform_v1_2() | extend x_SourceName = coalesce(x_SourceName, iff(ProviderName == 'Microsoft', 'Cost Management', ProviderName)) | extend x_SourceProvider = coalesce(x_SourceProvider, ProviderName) | extend x_SourceType = coalesce(x_SourceType, iff(ProviderName == 'Microsoft', 'ReservationRecommendations', '')) - | extend x_SourceVersion = coalesce(x_SourceVersion, iff(ProviderName == 'Microsoft', '2023-05-01', '')) + | extend x_SourceVersion = coalesce(x_SourceVersion, case( + x_SourceType =~ 'SavingsPlan', '2026-06-01', + ProviderName == 'Microsoft', '2023-05-01', + '' + )) // // Convert JSON cost columns to real | extend CostWithNoReservedInstances = case(isnotempty(CostWithNoReservedInstances), CostWithNoReservedInstances, isnotempty(CostWithNoReservedInstancesJson), toreal(extract(@'"value":([0-9\.]+)', 1, CostWithNoReservedInstancesJson)), CostWithNoReservedInstances) @@ -1731,6 +1735,7 @@ Recommendations_transform_v1_2() // Prefer specified date, then fall back to generating a date based on reservation recommendation lookback period, then validate to ensure it's not in the future | extend x_RecommendationDate = coalesce(x_RecommendationDate, FirstUsageDate + (toint(extract(@'^P([0-9]+)D$', 1, tostring(x_RecommendationDetails.LookbackPeriodDuration))) * 1d)) | extend x_RecommendationDate = iff(x_RecommendationDate > now(), startofday(now()), x_RecommendationDate) + | extend x_RecommendationId = coalesce(x_RecommendationId, ResourceId) // // Derive x_ResourceType from ResourceId | extend tmp_ResourceType = tostring(parse_resourceid(ResourceId).x_ResourceType) diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceGraph/app.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceGraph/app.bicep index 2b15ad38b..b7095b134 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceGraph/app.bicep +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceGraph/app.bicep @@ -218,6 +218,9 @@ resource pipeline_ExecuteQuery 'Microsoft.DataFactory/factories/pipelines@2018-0 query: { type: 'String' } + queryScope: { + type: 'String' + } querySource: { type: 'String' } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/README.md b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/README.md new file mode 100644 index 000000000..87c1b51d8 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/README.md @@ -0,0 +1,25 @@ +# AzureResourceManager engine app + +Query engine for Azure Resource Manager. Implements the `queries_{engineName}_ExecuteQuery` contract for the IngestionQueries orchestrator. + +## What it provides + +- **`azureResourceManager` dataset** — ADF REST dataset for a query-provided Azure Resource Manager relative URL +- **`queries_AzureResourceManager_ExecuteQuery` pipeline** — Executes one ARM GET request via REST and writes results as Parquet to the ingestion container + +## How it works + +1. IngestionQueries dispatches to this pipeline through the existing ADF REST API flow. +2. The pipeline uses the query string as the ARM-relative URL. +3. The Copy activity issues a GET request and follows the response `nextLink`. +4. The Copy activity uses the provided `translator` and writes Parquet to the `ingestionPath`. + +## Dependencies + +- **Core app** — Provides the `azurerm` linked service and the `ingestion` dataset. +- **Data Factory managed identity** — Requires read access to each configured ARM endpoint. + +## Limitations + +- Only GET requests are supported. +- Query files cannot configure the HTTP method, headers, body, authority, or authentication resource. diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/app.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/app.bicep new file mode 100644 index 000000000..ad121c5d0 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/app.bicep @@ -0,0 +1,1095 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { finOpsToolkitVersion, HubAppProperties, isSupportedVersion } from '../../fx/hub-types.bicep' +import { AppMetadata as CoreMetadata } from '../Core/metadata.bicep' +import { AppMetadata as AzureResourceManagerMetadata } from './metadata.bicep' + +metadata hubApp = { + id: 'Microsoft.FinOpsHubs.AzureResourceManager' + version: '$$ftkver$$' + dependencies: [ + 'Microsoft.FinOpsHubs.Core' + 'Microsoft.FinOpsHubs.IngestionQueries' + ] + metadata: 'https://microsoft.github.io/finops-toolkit/deploy/finops-hub/$$ftkver$$/Microsoft.FinOpsHubs/AzureResourceManager/metadata.bicep' +} + + +//============================================================================== +// Parameters +//============================================================================== + +@description('Required. FinOps hub app getting deployed.') +param app HubAppProperties + +@description('Required. Metadata describing shared resources from the Core app. Must be v13 or higher.') +@validate(x => isSupportedVersion(x.version, '13.0', ''), 'Core app version must be 13.0 or higher.') +param core CoreMetadata + + + +//============================================================================== +// Variables +//============================================================================== + + + +//============================================================================== +// Resources +//============================================================================== + +// Register app +module appRegistration '../../fx/hub-app.bicep' = { + name: 'Microsoft.FinOpsHubs.AzureResourceManager_Register' + params: { + app: app + version: finOpsToolkitVersion + features: [ + 'DataFactory' // ARM dataset and engine pipeline + ] + } +} + +// Get data factory instance +resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { + name: app.dataFactory + dependsOn: [appRegistration] +} + +//------------------------------------------------------------------------------ +// Datasets +//------------------------------------------------------------------------------ + +// Reference the ARM linked service (created by the Core app) +resource linkedService_arm 'Microsoft.DataFactory/factories/linkedservices@2018-06-01' existing = { + name: core.linkedServices.azurerm + parent: dataFactory +} + +// Azure Resource Manager dataset +resource dataset_azureResourceManager 'Microsoft.DataFactory/factories/datasets@2018-06-01' = { + name: 'azureResourceManager' + parent: dataFactory + properties: { + annotations: [] + parameters: { + relativeUrl: { + type: 'String' + } + } + type: 'RestResource' + typeProperties: { + relativeUrl: { + value: '@dataset().relativeUrl' + type: 'Expression' + } + } + linkedServiceName: { + parameters: {} + referenceName: linkedService_arm.name + type: 'LinkedServiceReference' + } + } +} + +// Reference existing Parquet dataset from Cost Management Exports +resource dataset_msexports_parquet 'Microsoft.DataFactory/factories/datasets@2018-06-01' existing = { + name: 'msexports_parquet' + parent: dataFactory +} + +// Reference existing configuration dataset from Core app +resource dataset_config 'Microsoft.DataFactory/factories/datasets@2018-06-01' existing = { + name: core.datasets.config + parent: dataFactory +} + +//------------------------------------------------------------------------------ +// Engine pipeline +//------------------------------------------------------------------------------ + +resource pipeline_ExecuteQuery 'Microsoft.DataFactory/factories/pipelines@2018-06-01' = { + name: 'queries_AzureResourceManager_ExecuteQuery' + parent: dataFactory + properties: { + description: 'Execute a GET request against Azure Resource Manager' + folder: { + name: 'FinOps hub' + } + activities: [ + { + name: 'If Configured Scope' + type: 'IfCondition' + dependsOn: [] + userProperties: [] + typeProperties: { + expression: { + value: '@equals(toLower(pipeline().parameters.queryScope), \'configured\')' + type: 'Expression' + } + ifTrueActivities: [ + { + name: 'Execute Configured Scopes' + type: 'ExecutePipeline' + dependsOn: [] + userProperties: [] + typeProperties: { + pipeline: { + referenceName: pipeline_ExecuteConfiguredScopes.name + type: 'PipelineReference' + } + waitOnCompletion: true + parameters: { + query: { + value: '@pipeline().parameters.query' + type: 'Expression' + } + queryType: { + value: '@pipeline().parameters.queryType' + type: 'Expression' + } + queryVersion: { + value: '@pipeline().parameters.queryVersion' + type: 'Expression' + } + ingestionPath: { + value: '@pipeline().parameters.ingestionPath' + type: 'Expression' + } + translator: { + value: '@pipeline().parameters.translator' + type: 'Expression' + } + } + } + } + ] + } + } + { + name: 'If Tenant Scope' + type: 'IfCondition' + dependsOn: [] + userProperties: [] + typeProperties: { + expression: { + value: '@equals(toLower(pipeline().parameters.queryScope), \'tenant\')' + type: 'Expression' + } + ifTrueActivities: [ + { + name: 'Execute Tenant' + type: 'ExecutePipeline' + dependsOn: [] + userProperties: [] + typeProperties: { + pipeline: { + referenceName: pipeline_ExecuteTenant.name + type: 'PipelineReference' + } + waitOnCompletion: true + parameters: { + query: { + value: '@pipeline().parameters.query' + type: 'Expression' + } + queryType: { + value: '@pipeline().parameters.queryType' + type: 'Expression' + } + queryVersion: { + value: '@pipeline().parameters.queryVersion' + type: 'Expression' + } + ingestionPath: { + value: '@pipeline().parameters.ingestionPath' + type: 'Expression' + } + translator: { + value: '@pipeline().parameters.translator' + type: 'Expression' + } + } + } + } + ] + } + } + { + name: 'If Direct Scope' + type: 'IfCondition' + dependsOn: [] + userProperties: [] + typeProperties: { + expression: { + value: '@and(not(equals(toLower(pipeline().parameters.queryScope), \'configured\')), not(equals(toLower(pipeline().parameters.queryScope), \'tenant\')))' + type: 'Expression' + } + ifTrueActivities: [ + { + name: 'Execute Request' + type: 'ExecutePipeline' + dependsOn: [] + userProperties: [] + typeProperties: { + pipeline: { + referenceName: pipeline_CopyQuery.name + type: 'PipelineReference' + } + waitOnCompletion: true + parameters: { + query: { + value: '@pipeline().parameters.query' + type: 'Expression' + } + queryScope: { + value: '@pipeline().parameters.queryScope' + type: 'Expression' + } + queryLocation: '' + queryType: { + value: '@pipeline().parameters.queryType' + type: 'Expression' + } + queryVersion: { + value: '@pipeline().parameters.queryVersion' + type: 'Expression' + } + ingestionPath: { + value: '@pipeline().parameters.ingestionPath' + type: 'Expression' + } + translator: { + value: '@pipeline().parameters.translator' + type: 'Expression' + } + } + } + } + ] + } + } + ] + parameters: { + ingestionPath: { + type: 'String' + } + query: { + type: 'String' + } + queryDataset: { + type: 'String' + } + queryProvider: { + type: 'String' + } + queryEngine: { + type: 'String' + } + queryScope: { + type: 'String' + } + querySource: { + type: 'String' + } + queryType: { + type: 'String' + } + queryVersion: { + type: 'String' + } + translator: { + type: 'Object' + } + } + } +} + +resource pipeline_ExecuteConfiguredScopes 'Microsoft.DataFactory/factories/pipelines@2018-06-01' = { + name: 'queries_AzureResourceManager_ExecuteConfiguredScopes' + parent: dataFactory + properties: { + description: 'Execute an Azure Resource Manager query for each configured billing scope' + folder: { + name: 'FinOps hub' + } + activities: [ + { + name: 'Get Config' + type: 'Lookup' + dependsOn: [] + policy: { + timeout: '0.00:10:00' + retry: 2 + retryIntervalInSeconds: 30 + secureOutput: false + secureInput: false + } + userProperties: [] + typeProperties: { + source: { + type: 'JsonSource' + storeSettings: { + type: 'AzureBlobFSReadSettings' + recursive: true + enablePartitionDiscovery: false + } + formatSettings: { + type: 'JsonReadSettings' + } + } + dataset: { + referenceName: dataset_config.name + type: 'DatasetReference' + parameters: { + fileName: { + value: '@variables(\'fileName\')' + type: 'Expression' + } + folderPath: { + value: '@variables(\'folderPath\')' + type: 'Expression' + } + } + } + firstRowOnly: true + } + } + { + name: 'Set Scopes' + description: 'Normalize one or more configured scope objects into an array.' + type: 'SetVariable' + dependsOn: [ + { + activity: 'Get Config' + dependencyConditions: [ + 'Succeeded' + ] + } + ] + userProperties: [] + typeProperties: { + variableName: 'scopesArray' + value: { + value: '@if(startswith(string(activity(\'Get Config\').output.firstRow.scopes), \'[\'), activity(\'Get Config\').output.firstRow.scopes, createArray(activity(\'Get Config\').output.firstRow.scopes))' + type: 'Expression' + } + } + } + { + name: 'Filter Invalid Scopes' + description: 'Filter out scopes that are not defined or that are not Microsoft.Billing scopes.' + type: 'Filter' + dependsOn: [ + { + activity: 'Set Scopes' + dependencyConditions: [ + 'Succeeded' + ] + } + ] + userProperties: [] + typeProperties: { + items: { + value: '@variables(\'scopesArray\')' + type: 'Expression' + } + condition: { + value: '@and(not(empty(item().scope)), startswith(toLower(item().scope), \'/providers/microsoft.billing/\'))' + type: 'Expression' + } + } + } + { + name: 'ForEach Scope' + type: 'ForEach' + dependsOn: [ + { + activity: 'Filter Invalid Scopes' + dependencyConditions: [ + 'Succeeded' + ] + } + ] + userProperties: [] + typeProperties: { + items: { + value: '@activity(\'Filter Invalid Scopes\').output.value' + type: 'Expression' + } + isSequential: false + batchCount: app.hub.options.privateRouting ? 4 : 30 + activities: [ + { + name: 'Execute Request' + type: 'ExecutePipeline' + dependsOn: [] + userProperties: [] + typeProperties: { + pipeline: { + referenceName: pipeline_CopyQuery.name + type: 'PipelineReference' + } + waitOnCompletion: true + parameters: { + query: { + value: '@pipeline().parameters.query' + type: 'Expression' + } + queryScope: { + value: '@item().scope' + type: 'Expression' + } + queryLocation: '' + queryType: { + value: '@pipeline().parameters.queryType' + type: 'Expression' + } + queryVersion: { + value: '@pipeline().parameters.queryVersion' + type: 'Expression' + } + ingestionPath: { + value: '@pipeline().parameters.ingestionPath' + type: 'Expression' + } + translator: { + value: '@pipeline().parameters.translator' + type: 'Expression' + } + } + } + } + ] + } + } + ] + parameters: { + ingestionPath: { + type: 'String' + } + query: { + type: 'String' + } + queryType: { + type: 'String' + } + queryVersion: { + type: 'String' + } + translator: { + type: 'Object' + } + } + variables: { + fileName: { + type: 'String' + defaultValue: core.settings.file + } + folderPath: { + type: 'String' + defaultValue: core.settings.container + } + scopesArray: { + type: 'Array' + } + } + } +} + +resource pipeline_ExecuteTenant 'Microsoft.DataFactory/factories/pipelines@2018-06-01' = { + name: 'queries_AzureResourceManager_ExecuteTenant' + parent: dataFactory + properties: { + description: 'Execute an Azure Resource Manager query for each enabled subscription' + folder: { + name: 'FinOps hub' + } + activities: [ + { + name: 'Get Subscriptions' + type: 'WebActivity' + dependsOn: [] + policy: { + timeout: '0.00:02:00' + retry: 0 + retryIntervalInSeconds: 30 + secureOutput: false + secureInput: false + } + userProperties: [] + typeProperties: { + method: 'GET' + url: '${environment().resourceManager}subscriptions?api-version=2022-12-01' + authentication: { + type: 'MSI' + resource: environment().resourceManager + } + } + } + { + name: 'Filter Enabled Subscriptions' + type: 'Filter' + dependsOn: [ + { + activity: 'Get Subscriptions' + dependencyConditions: [ + 'Succeeded' + ] + } + ] + userProperties: [] + typeProperties: { + items: { + value: '@activity(\'Get Subscriptions\').output.value' + type: 'Expression' + } + condition: { + value: '@equals(toLower(item().state), \'enabled\')' + type: 'Expression' + } + } + } + { + name: 'ForEach Subscription' + type: 'ForEach' + dependsOn: [ + { + activity: 'Filter Enabled Subscriptions' + dependencyConditions: [ + 'Succeeded' + ] + } + ] + userProperties: [] + typeProperties: { + items: { + value: '@activity(\'Filter Enabled Subscriptions\').output.value' + type: 'Expression' + } + isSequential: false + batchCount: app.hub.options.privateRouting ? 4 : 30 + activities: [ + { + name: 'Execute Subscription Query' + type: 'ExecutePipeline' + dependsOn: [] + userProperties: [] + typeProperties: { + pipeline: { + referenceName: pipeline_ExecuteSubscription.name + type: 'PipelineReference' + } + waitOnCompletion: true + parameters: { + query: { + value: '@pipeline().parameters.query' + type: 'Expression' + } + queryScope: { + value: '@item().id' + type: 'Expression' + } + queryType: { + value: '@pipeline().parameters.queryType' + type: 'Expression' + } + queryVersion: { + value: '@pipeline().parameters.queryVersion' + type: 'Expression' + } + ingestionPath: { + value: '@pipeline().parameters.ingestionPath' + type: 'Expression' + } + translator: { + value: '@pipeline().parameters.translator' + type: 'Expression' + } + } + } + } + ] + } + } + ] + parameters: { + ingestionPath: { + type: 'String' + } + query: { + type: 'String' + } + queryType: { + type: 'String' + } + queryVersion: { + type: 'String' + } + translator: { + type: 'Object' + } + } + } +} + +resource pipeline_ExecuteSubscription 'Microsoft.DataFactory/factories/pipelines@2018-06-01' = { + name: 'queries_AzureResourceManager_ExecuteSubscription' + parent: dataFactory + properties: { + description: 'Execute a direct or regional Azure Resource Manager query for one subscription' + folder: { + name: 'FinOps hub' + } + activities: [ + { + name: 'If Direct Query' + type: 'IfCondition' + dependsOn: [] + userProperties: [] + typeProperties: { + expression: { + value: '@not(contains(pipeline().parameters.query, \'{location}\'))' + type: 'Expression' + } + ifTrueActivities: [ + { + name: 'Execute Request' + type: 'ExecutePipeline' + dependsOn: [] + userProperties: [] + typeProperties: { + pipeline: { + referenceName: pipeline_CopyQuery.name + type: 'PipelineReference' + } + waitOnCompletion: true + parameters: { + query: { + value: '@pipeline().parameters.query' + type: 'Expression' + } + queryScope: { + value: '@pipeline().parameters.queryScope' + type: 'Expression' + } + queryLocation: '' + queryType: { + value: '@pipeline().parameters.queryType' + type: 'Expression' + } + queryVersion: { + value: '@pipeline().parameters.queryVersion' + type: 'Expression' + } + ingestionPath: { + value: '@pipeline().parameters.ingestionPath' + type: 'Expression' + } + translator: { + value: '@pipeline().parameters.translator' + type: 'Expression' + } + } + } + } + ] + } + } + { + name: 'If Regional Query' + type: 'IfCondition' + dependsOn: [] + userProperties: [] + typeProperties: { + expression: { + value: '@contains(pipeline().parameters.query, \'{location}\')' + type: 'Expression' + } + ifTrueActivities: [ + { + name: 'Execute Regional Query' + type: 'ExecutePipeline' + dependsOn: [] + userProperties: [] + typeProperties: { + pipeline: { + referenceName: pipeline_ExecuteRegional.name + type: 'PipelineReference' + } + waitOnCompletion: true + parameters: { + query: { + value: '@pipeline().parameters.query' + type: 'Expression' + } + queryScope: { + value: '@pipeline().parameters.queryScope' + type: 'Expression' + } + queryType: { + value: '@pipeline().parameters.queryType' + type: 'Expression' + } + queryVersion: { + value: '@pipeline().parameters.queryVersion' + type: 'Expression' + } + ingestionPath: { + value: '@pipeline().parameters.ingestionPath' + type: 'Expression' + } + translator: { + value: '@pipeline().parameters.translator' + type: 'Expression' + } + } + } + } + ] + } + } + ] + parameters: { + ingestionPath: { + type: 'String' + } + query: { + type: 'String' + } + queryScope: { + type: 'String' + } + queryType: { + type: 'String' + } + queryVersion: { + type: 'String' + } + translator: { + type: 'Object' + } + } + } +} + +resource pipeline_ExecuteRegional 'Microsoft.DataFactory/factories/pipelines@2018-06-01' = { + name: 'queries_AzureResourceManager_ExecuteRegional' + parent: dataFactory + properties: { + description: 'Execute an Azure Resource Manager query for each physical region in one subscription' + folder: { + name: 'FinOps hub' + } + activities: [ + { + name: 'Get Locations' + type: 'WebActivity' + dependsOn: [] + policy: { + timeout: '0.00:02:00' + retry: 0 + retryIntervalInSeconds: 30 + secureOutput: false + secureInput: false + } + userProperties: [] + typeProperties: { + method: 'GET' + url: { + value: '@concat(\'${environment().resourceManager}\', pipeline().parameters.queryScope, \'/locations?api-version=2022-12-01\')' + type: 'Expression' + } + authentication: { + type: 'MSI' + resource: environment().resourceManager + } + } + } + { + name: 'Get Provider' + type: 'WebActivity' + dependsOn: [] + policy: { + timeout: '0.00:02:00' + retry: 0 + retryIntervalInSeconds: 30 + secureOutput: false + secureInput: false + } + userProperties: [] + typeProperties: { + method: 'GET' + url: { + value: '@concat(\'${environment().resourceManager}\', pipeline().parameters.queryScope, \'/providers/\', split(pipeline().parameters.query, \'/\')[2], \'?api-version=2021-04-01\')' + type: 'Expression' + } + authentication: { + type: 'MSI' + resource: environment().resourceManager + } + } + } + { + name: 'Filter Provider Resource Type' + type: 'Filter' + dependsOn: [ + { + activity: 'Get Provider' + dependencyConditions: [ + 'Succeeded' + ] + } + ] + userProperties: [] + typeProperties: { + items: { + value: '@activity(\'Get Provider\').output.resourceTypes' + type: 'Expression' + } + condition: { + value: '@equals(toLower(item().resourceType), \'locations/usages\')' + type: 'Expression' + } + } + } + { + name: 'Filter Physical Locations' + type: 'Filter' + dependsOn: [ + { + activity: 'Get Locations' + dependencyConditions: [ + 'Succeeded' + ] + } + { + activity: 'Filter Provider Resource Type' + dependencyConditions: [ + 'Succeeded' + ] + } + ] + userProperties: [] + typeProperties: { + items: { + value: '@activity(\'Get Locations\').output.value' + type: 'Expression' + } + condition: { + value: '@and(equals(toLower(item().metadata.regionType), \'physical\'), contains(activity(\'Filter Provider Resource Type\').output.value[0].locations, item().displayName))' + type: 'Expression' + } + } + } + { + name: 'ForEach Location' + type: 'ForEach' + dependsOn: [ + { + activity: 'Filter Physical Locations' + dependencyConditions: [ + 'Succeeded' + ] + } + ] + userProperties: [] + typeProperties: { + items: { + value: '@activity(\'Filter Physical Locations\').output.value' + type: 'Expression' + } + isSequential: false + batchCount: app.hub.options.privateRouting ? 4 : 30 + activities: [ + { + name: 'Execute Request' + type: 'ExecutePipeline' + dependsOn: [] + userProperties: [] + typeProperties: { + pipeline: { + referenceName: pipeline_CopyQuery.name + type: 'PipelineReference' + } + waitOnCompletion: true + parameters: { + query: { + value: '@pipeline().parameters.query' + type: 'Expression' + } + queryScope: { + value: '@pipeline().parameters.queryScope' + type: 'Expression' + } + queryLocation: { + value: '@item().name' + type: 'Expression' + } + queryType: { + value: '@pipeline().parameters.queryType' + type: 'Expression' + } + queryVersion: { + value: '@pipeline().parameters.queryVersion' + type: 'Expression' + } + ingestionPath: { + value: '@pipeline().parameters.ingestionPath' + type: 'Expression' + } + translator: { + value: '@pipeline().parameters.translator' + type: 'Expression' + } + } + } + } + ] + } + } + ] + parameters: { + ingestionPath: { + type: 'String' + } + query: { + type: 'String' + } + queryScope: { + type: 'String' + } + queryType: { + type: 'String' + } + queryVersion: { + type: 'String' + } + translator: { + type: 'Object' + } + } + } +} + +//------------------------------------------------------------------------------ +// Request Copy pipeline +//------------------------------------------------------------------------------ + +resource pipeline_CopyQuery 'Microsoft.DataFactory/factories/pipelines@2018-06-01' = { + name: 'queries_AzureResourceManager_CopyQuery' + parent: dataFactory + properties: { + concurrency: 1 + activities: [ + { + name: 'Execute ARM Query' + description: 'Execute one ARM request and write Parquet to msexports staging for pre-manifest consolidation.' + type: 'Copy' + dependsOn: [] + policy: { + timeout: '0.00:10:00' + retry: 0 + retryIntervalInSeconds: 60 + secureOutput: false + secureInput: false + } + userProperties: [] + typeProperties: { + source: { + type: 'RestSource' + httpRequestTimeout: '00:02:00' + requestInterval: '00.00:00:00.050' + requestMethod: 'GET' + paginationRules: { + AbsoluteUrl: '$.nextLink' + } + } + sink: { + type: 'ParquetSink' + storeSettings: { + type: 'AzureBlobFSWriteSettings' + } + formatSettings: { + type: 'ParquetWriteSettings' + } + } + enableStaging: false + translator: { + value: '@pipeline().parameters.translator' + type: 'Expression' + } + } + inputs: [ + { + referenceName: dataset_azureResourceManager.name + type: 'DatasetReference' + parameters: { + relativeUrl: { + value: '@concat(pipeline().parameters.queryScope, replace(pipeline().parameters.query, \'{location}\', pipeline().parameters.queryLocation))' + type: 'Expression' + } + } + } + ] + outputs: [ + { + referenceName: dataset_msexports_parquet.name + type: 'DatasetReference' + parameters: { + blobPath: { + value: '@concat(\'_ftk-query-staging/\', replace(pipeline().parameters.ingestionPath, concat(pipeline().parameters.queryType, \'.parquet\'), \'\'), \'/SubAccountId=\', last(split(pipeline().parameters.queryScope, \'/\')), if(empty(pipeline().parameters.queryLocation), \'\', concat(\'/location=\', pipeline().parameters.queryLocation)), \'/x_SourceName=Azure Resource Manager/x_SourceProvider=Microsoft\', if(contains(string(pipeline().parameters.translator), \'x_SourceType\'), \'\', concat(\'/x_SourceType=\', pipeline().parameters.queryType)), \'/x_SourceVersion=\', pipeline().parameters.queryVersion, \'/\', pipeline().parameters.queryType, \'--\', pipeline().parameters.queryVersion, \'--\', replace(pipeline().parameters.queryScope, \'/\', \'_\'), \'--\', pipeline().parameters.queryLocation, \'.parquet\')' + type: 'Expression' + } + } + } + ] + } + ] + parameters: { + query: { + type: 'String' + } + queryScope: { + type: 'String' + } + queryLocation: { + type: 'String' + } + queryType: { + type: 'String' + } + queryVersion: { + type: 'String' + } + ingestionPath: { + type: 'String' + } + translator: { + type: 'Object' + } + } + policy: { + elapsedTimeMetric: {} + } + annotations: [] + } +} + + +//============================================================================== +// Outputs +//============================================================================== + +@description('The app properties for the AzureResourceManager app.') +output app HubAppProperties = app + +@description('Metadata describing resources created by the AzureResourceManager app.') +output metadata AzureResourceManagerMetadata = { + id: 'Microsoft.FinOpsHubs.AzureResourceManager' + version: finOpsToolkitVersion + datasets: { + azureResourceManager: dataset_azureResourceManager.name + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/metadata.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/metadata.bicep new file mode 100644 index 000000000..4b841e8d2 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/AzureResourceManager/metadata.bicep @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +@export() +@description('Metadata for resources created by the Azure Resource Manager app.') +type AppMetadata = { + @description('Fully-qualified app identifier.') + id: string + @description('App version.') + version: string + @description('Data Factory dataset names.') + datasets: { + @description('Dataset for Azure Resource Manager REST APIs.') + azureResourceManager: string + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/IngestionQueries/app.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/IngestionQueries/app.bicep index 38fcfb22b..0bfefc211 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/IngestionQueries/app.bicep +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/IngestionQueries/app.bicep @@ -53,6 +53,9 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { name: app.dataFactory dependsOn: [appRegistration] + resource linkedService_storageAccount 'linkedservices@2018-06-01' existing = { + name: app.storage + } resource dataset_config 'datasets@2018-06-01' existing = { name: core.datasets.config } @@ -65,6 +68,31 @@ resource dataFactory 'Microsoft.DataFactory/factories@2018-06-01' existing = { resource dataset_manifest 'datasets@2018-06-01' existing = { name: core.datasets.ingestionManifest } + resource dataset_msexports_parquet_files 'datasets@2018-06-01' = { + name: 'msexports_parquet_files' + properties: { + linkedServiceName: { + referenceName: linkedService_storageAccount.name + type: 'LinkedServiceReference' + } + parameters: { + folderPath: { + type: 'String' + } + } + type: 'Parquet' + typeProperties: { + location: { + type: 'AzureBlobFSLocation' + fileSystem: 'msexports' + folderPath: { + value: '@dataset().folderPath' + type: 'Expression' + } + } + } + } + } } //------------------------------------------------------------------------------ @@ -184,7 +212,7 @@ resource pipeline_ExecuteQueries 'Microsoft.DataFactory/factories/pipelines@2018 value: '@activity(\'Load Queries\').output.value' type: 'Expression' } - batchCount: 2 + batchCount: app.hub.options.privateRouting ? 4 : 30 isSequential: false activities: [ { // Execute File Queries @@ -494,7 +522,7 @@ resource pipeline_ExecuteQueries_query 'Microsoft.DataFactory/factories/pipeline 'Content-Type': 'application/json' } body: { - value: '@json(concat(\'{"query":"\', pipeline().parameters.query, \'","querySource":"\', pipeline().parameters.querySource, \'","queryType":"\', pipeline().parameters.queryType, \'","queryProvider":"\', pipeline().parameters.queryProvider, \'","queryVersion":"\', pipeline().parameters.queryVersion, \'","ingestionPath":"\', concat(variables(\'ingestionPath\'), pipeline().parameters.queryType, \'.parquet\'), \'","translator":\', string(activity(\'Load Schema Mappings\').output.firstRow.translator), \'}\'))' + value: '@json(concat(\'{"query":"\', pipeline().parameters.query, \'","queryScope":"\', pipeline().parameters.queryScope, \'","querySource":"\', pipeline().parameters.querySource, \'","queryType":"\', pipeline().parameters.queryType, \'","queryProvider":"\', pipeline().parameters.queryProvider, \'","queryVersion":"\', pipeline().parameters.queryVersion, \'","ingestionPath":"\', concat(variables(\'ingestionPath\'), pipeline().parameters.queryType, \'.parquet\'), \'","translator":\', string(activity(\'Load Schema Mappings\').output.firstRow.translator), \'}\'))' type: 'Expression' } authentication: { @@ -623,7 +651,7 @@ resource pipeline_ExecuteQueries_query 'Microsoft.DataFactory/factories/pipeline type: 'GetMetadata' dependsOn: [ { - activity: 'Verify Query Engine Pipeline Succeeded' + activity: 'Finalize ARM Query' dependencyConditions: ['Succeeded'] } ] @@ -653,6 +681,163 @@ resource pipeline_ExecuteQueries_query 'Microsoft.DataFactory/factories/pipeline } } } + { // Check ARM Query Staging + name: 'Check ARM Query Staging' + type: 'GetMetadata' + dependsOn: [ + { + activity: 'Verify Query Engine Pipeline Succeeded' + dependencyConditions: ['Succeeded'] + } + ] + policy: { + timeout: '0.00:10:00' + retry: 0 + retryIntervalInSeconds: 30 + secureOutput: false + secureInput: false + } + userProperties: [] + typeProperties: { + dataset: { + referenceName: dataFactory::dataset_msexports_parquet_files.name + type: 'DatasetReference' + parameters: { + folderPath: { + value: '@concat(\'_ftk-query-staging/\', variables(\'ingestionPath\'))' + type: 'Expression' + } + } + } + fieldList: ['exists'] + storeSettings: { + type: 'AzureBlobFSReadSettings' + recursive: true + enablePartitionDiscovery: false + } + formatSettings: { + type: 'ParquetReadSettings' + } + } + } + { // Finalize ARM Query + name: 'Finalize ARM Query' + description: 'Consolidate staged ARM results into one ingestion file before manifest creation.' + type: 'IfCondition' + dependsOn: [ + { + activity: 'Check ARM Query Staging' + dependencyConditions: ['Succeeded'] + } + ] + userProperties: [] + typeProperties: { + expression: { + value: '@and(equals(toLower(pipeline().parameters.queryEngine), \'azureresourcemanager\'), activity(\'Check ARM Query Staging\').output.exists)' + type: 'Expression' + } + ifTrueActivities: [ + { + name: 'Consolidate ARM Query' + type: 'Copy' + dependsOn: [] + policy: { + timeout: '0.00:30:00' + retry: 0 + retryIntervalInSeconds: 30 + secureOutput: false + secureInput: false + } + userProperties: [] + typeProperties: { + source: { + type: 'ParquetSource' + storeSettings: { + type: 'AzureBlobFSReadSettings' + recursive: true + wildcardFileName: '*.parquet' + enablePartitionDiscovery: true + } + formatSettings: { + type: 'ParquetReadSettings' + } + } + sink: { + type: 'ParquetSink' + storeSettings: { + type: 'AzureBlobFSWriteSettings' + copyBehavior: 'MergeFiles' + } + formatSettings: { + type: 'ParquetWriteSettings' + } + } + enableStaging: false + } + inputs: [ + { + referenceName: dataFactory::dataset_msexports_parquet_files.name + type: 'DatasetReference' + parameters: { + folderPath: { + value: '@concat(\'_ftk-query-staging/\', variables(\'ingestionPath\'))' + type: 'Expression' + } + } + } + ] + outputs: [ + { + referenceName: dataFactory::dataset_ingestion.name + type: 'DatasetReference' + parameters: { + blobPath: { + value: '@concat(variables(\'ingestionPath\'), pipeline().parameters.queryType, \'.parquet\')' + type: 'Expression' + } + } + } + ] + } + { + name: 'Delete ARM Query Staging' + type: 'Delete' + dependsOn: [ + { + activity: 'Consolidate ARM Query' + dependencyConditions: ['Succeeded'] + } + ] + policy: { + timeout: '0.00:10:00' + retry: 0 + retryIntervalInSeconds: 30 + secureOutput: false + secureInput: false + } + userProperties: [] + typeProperties: { + dataset: { + referenceName: dataFactory::dataset_msexports_parquet_files.name + type: 'DatasetReference' + parameters: { + folderPath: { + value: '@concat(\'_ftk-query-staging/\', variables(\'ingestionPath\'))' + type: 'Expression' + } + } + } + enableLogging: false + storeSettings: { + type: 'AzureBlobFSReadSettings' + recursive: true + enablePartitionDiscovery: false + } + } + } + ] + } + } { // Create Manifest If Data Exists name: 'Create Manifest If Data Exists' description: 'Only create a manifest file when query results were written, to avoid triggering ADX ingestion on empty folders.' @@ -666,7 +851,7 @@ resource pipeline_ExecuteQueries_query 'Microsoft.DataFactory/factories/pipeline userProperties: [] typeProperties: { expression: { - value: '@activity(\'Check If Data Was Written\').output.exists' + value: '@and(pipeline().parameters.publishManifest, activity(\'Check If Data Was Written\').output.exists)' type: 'Expression' } ifTrueActivities: [ @@ -765,6 +950,10 @@ resource pipeline_ExecuteQueries_query 'Microsoft.DataFactory/factories/pipeline queryType: { type: 'String' } + publishManifest: { + type: 'Bool' + defaultValue: true + } } variables: { queryScope: { diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/app.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/app.bicep new file mode 100644 index 000000000..8612fa71b --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/app.bicep @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { finOpsToolkitVersion, HubAppProperties, isSupportedVersion } from '../../fx/hub-types.bicep' +import { AppMetadata as CoreMetadata } from '../Core/metadata.bicep' +import { AppMetadata as IngestionQueriesMetadata } from '../IngestionQueries/metadata.bicep' + +metadata hubApp = { + id: 'Microsoft.FinOpsHubs.Quota' + version: '$$ftkver$$' + dependencies: [ + 'Microsoft.FinOpsHubs.Core' + 'Microsoft.FinOpsHubs.IngestionQueries' + 'Microsoft.FinOpsHubs.AzureResourceManager' + ] + metadata: 'https://microsoft.github.io/finops-toolkit/deploy/finops-hub/$$ftkver$$/Microsoft.FinOpsHubs/Quota/metadata.bicep' +} + + +//============================================================================== +// Parameters +//============================================================================== + +@description('Required. FinOps hub app getting deployed.') +param app HubAppProperties + +@description('Required. Metadata describing shared resources from the Core app. Must be v13 or higher.') +@validate(x => isSupportedVersion(x.version, '13.0', ''), 'Core app version must be 13.0 or higher.') +param core CoreMetadata + +@description('Required. Metadata describing resources from the Ingestion Queries app. Must be v13 or higher.') +@validate(x => isSupportedVersion(x.version, '13.0', ''), 'IngestionQueries app version must be 13.0 or higher.') +param ingestionQueries IngestionQueriesMetadata + +//============================================================================== +// Variables +//============================================================================== + +// +// Load query files -- quota queries are always included +var coreQueryFiles = { + 'Quota-Microsoft-AppServiceUsage': loadTextContent('queries/Quota-Microsoft-AppServiceUsage.json') + 'Quota-Microsoft-CapacityReservation': loadTextContent('queries/Quota-Microsoft-CapacityReservation.json') + 'Quota-Microsoft-CognitiveServicesUsage': loadTextContent('queries/Quota-Microsoft-CognitiveServicesUsage.json') + 'Quota-Microsoft-ComputeUsage': loadTextContent('queries/Quota-Microsoft-ComputeUsage.json') + 'Quota-Microsoft-PremiumSSDv2Disk': loadTextContent('queries/Quota-Microsoft-PremiumSSDv2Disk.json') + 'Quota-Microsoft-SqlSubscriptionUsage': loadTextContent('queries/Quota-Microsoft-SqlSubscriptionUsage.json') + 'Quota-Microsoft-StorageUsage': loadTextContent('queries/Quota-Microsoft-StorageUsage.json') +} + +var queryFiles = coreQueryFiles +// + +// Load schema files +var schemaFiles = { + 'quota_1.0-capacity-reservation': loadTextContent('schemas/quota_1.0-capacity-reservation.json') + 'quota_1.0-disk': loadTextContent('schemas/quota_1.0-disk.json') + 'quota_1.0-sql': loadTextContent('schemas/quota_1.0-sql.json') + 'quota_1.0-usage': loadTextContent('schemas/quota_1.0-usage.json') +} + + +//============================================================================== +// Resources +//============================================================================== + +// Register app +module appRegistration '../../fx/hub-app.bicep' = { + name: 'Microsoft.FinOpsHubs.Quota_Register' + params: { + app: app + version: finOpsToolkitVersion + features: [ + 'Storage' // Storing queries and schemas + ] + } +} + +//------------------------------------------------------------------------------ +// Storage +//------------------------------------------------------------------------------ + +// Upload query files to storage +module uploadQueries '../../fx/hub-storage.bicep' = { + name: 'Microsoft.FinOpsHubs.Quota_UploadQueries' + dependsOn: [appRegistration] + params: { + app: app + container: ingestionQueries.queries.container + files: reduce(items(queryFiles), {}, (acc, item) => union(acc, { '${ingestionQueries.queries.folder}/${item.key}.json': item.value })) + } +} + +// Upload schema files to storage +module uploadSchemas '../../fx/hub-storage.bicep' = { + name: 'Microsoft.FinOpsHubs.Quota_UploadSchemas' + dependsOn: [appRegistration] + params: { + app: app + container: core.containers.config + files: reduce(items(schemaFiles), {}, (acc, item) => union(acc, { 'schemas/${item.key}.json': item.value })) + } +} + + +//============================================================================== +// Outputs +//============================================================================== + +@description('The app properties for the Quota app.') +output app HubAppProperties = app diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-AppServiceUsage.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-AppServiceUsage.json new file mode 100644 index 000000000..6ee93f39d --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-AppServiceUsage.json @@ -0,0 +1,10 @@ +{ + "dataset": "Quota", + "provider": "Microsoft", + "query": "/providers/Microsoft.Web/locations/{location}/usages?api-version=2024-11-01", + "queryEngine": "AzureResourceManager", + "scope": "Tenant", + "source": "Azure Resource Manager", + "type": "AppServiceUsage", + "version": "1.0-usage" +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-CapacityReservation.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-CapacityReservation.json new file mode 100644 index 000000000..c7f8937b3 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-CapacityReservation.json @@ -0,0 +1,10 @@ +{ + "dataset": "Quota", + "provider": "Microsoft", + "query": "/providers/Microsoft.Compute/capacityReservationGroups?api-version=2024-03-01", + "queryEngine": "AzureResourceManager", + "scope": "Tenant", + "source": "Azure Resource Manager", + "type": "CapacityReservation", + "version": "1.0-capacity-reservation" +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-CognitiveServicesUsage.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-CognitiveServicesUsage.json new file mode 100644 index 000000000..1c036badf --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-CognitiveServicesUsage.json @@ -0,0 +1,10 @@ +{ + "dataset": "Quota", + "provider": "Microsoft", + "query": "/providers/Microsoft.CognitiveServices/locations/{location}/usages?api-version=2023-05-01", + "queryEngine": "AzureResourceManager", + "scope": "Tenant", + "source": "Azure Resource Manager", + "type": "CognitiveServicesUsage", + "version": "1.0-usage" +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-ComputeUsage.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-ComputeUsage.json new file mode 100644 index 000000000..c7b15597d --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-ComputeUsage.json @@ -0,0 +1,10 @@ +{ + "dataset": "Quota", + "provider": "Microsoft", + "query": "/providers/Microsoft.Compute/locations/{location}/usages?api-version=2024-07-01", + "queryEngine": "AzureResourceManager", + "scope": "Tenant", + "source": "Azure Resource Manager", + "type": "ComputeUsage", + "version": "1.0-usage" +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-PremiumSSDv2Disk.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-PremiumSSDv2Disk.json new file mode 100644 index 000000000..0e8cefff5 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-PremiumSSDv2Disk.json @@ -0,0 +1,10 @@ +{ + "dataset": "Quota", + "provider": "Microsoft", + "query": "/providers/Microsoft.Compute/disks?api-version=2024-03-02", + "queryEngine": "AzureResourceManager", + "scope": "Tenant", + "source": "Azure Resource Manager", + "type": "PremiumSSDv2Disk", + "version": "1.0-disk" +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-SqlSubscriptionUsage.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-SqlSubscriptionUsage.json new file mode 100644 index 000000000..d6edf1b7b --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-SqlSubscriptionUsage.json @@ -0,0 +1,10 @@ +{ + "dataset": "Quota", + "provider": "Microsoft", + "query": "/providers/Microsoft.Sql/locations/{location}/usages?api-version=2023-08-01", + "queryEngine": "AzureResourceManager", + "scope": "Tenant", + "source": "Azure Resource Manager", + "type": "SqlSubscriptionUsage", + "version": "1.0-sql" +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-StorageUsage.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-StorageUsage.json new file mode 100644 index 000000000..3044a02e3 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/queries/Quota-Microsoft-StorageUsage.json @@ -0,0 +1,10 @@ +{ + "dataset": "Quota", + "provider": "Microsoft", + "query": "/providers/Microsoft.Storage/locations/{location}/usages?api-version=2025-06-01", + "queryEngine": "AzureResourceManager", + "scope": "Tenant", + "source": "Azure Resource Manager", + "type": "StorageUsage", + "version": "1.0-usage" +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-capacity-reservation.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-capacity-reservation.json new file mode 100644 index 000000000..202f020c0 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-capacity-reservation.json @@ -0,0 +1,42 @@ +{ + "additionalColumns": [], + "translator": { + "type": "TabularTranslator", + "mappings": [ + { + "source": { + "path": "['id']" + }, + "sink": { + "name": "ResourceId" + } + }, + { + "source": { + "path": "['name']" + }, + "sink": { + "name": "ResourceName" + } + }, + { + "source": { + "path": "['type']" + }, + "sink": { + "name": "ResourceType" + } + }, + { + "source": { + "path": "['location']" + }, + "sink": { + "name": "location" + } + } + ], + "collectionReference": "$['value']", + "mapComplexValuesToString": true + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-disk.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-disk.json new file mode 100644 index 000000000..4a03a9ef6 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-disk.json @@ -0,0 +1,58 @@ +{ + "additionalColumns": [], + "translator": { + "type": "TabularTranslator", + "mappings": [ + { + "source": { + "path": "['id']" + }, + "sink": { + "name": "ResourceId" + } + }, + { + "source": { + "path": "['name']" + }, + "sink": { + "name": "ResourceName" + } + }, + { + "source": { + "path": "['type']" + }, + "sink": { + "name": "ResourceType" + } + }, + { + "source": { + "path": "['sku']['name']" + }, + "sink": { + "name": "displayName" + } + }, + { + "source": { + "path": "['location']" + }, + "sink": { + "name": "location" + } + }, + { + "source": { + "path": "['properties']['diskSizeGB']" + }, + "sink": { + "name": "currentValue" + } + } + ], + "collectionReference": "$['value']", + "mapComplexValuesToString": true + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-sql.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-sql.json new file mode 100644 index 000000000..f417ea9c7 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-sql.json @@ -0,0 +1,73 @@ +{ + "additionalColumns": [], + "translator": { + "type": "TabularTranslator", + "mappings": [ + { + "source": { + "path": "['id']" + }, + "sink": { + "name": "ResourceId", + "type": "String" + } + }, + { + "source": { + "path": "['name']" + }, + "sink": { + "name": "ResourceName", + "type": "String" + } + }, + { + "source": { + "path": "['type']" + }, + "sink": { + "name": "ResourceType", + "type": "String" + } + }, + { + "source": { + "path": "['properties']['displayName']" + }, + "sink": { + "name": "displayName", + "type": "String" + } + }, + { + "source": { + "path": "['properties']['currentValue']" + }, + "sink": { + "name": "currentValue", + "type": "Double" + } + }, + { + "source": { + "path": "['properties']['limit']" + }, + "sink": { + "name": "limit", + "type": "Double" + } + }, + { + "source": { + "path": "['properties']['unit']" + }, + "sink": { + "name": "unit", + "type": "String" + } + } + ], + "collectionReference": "$['value']", + "mapComplexValuesToString": true + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-usage.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-usage.json new file mode 100644 index 000000000..f1bf6495a --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Quota/schemas/quota_1.0-usage.json @@ -0,0 +1,59 @@ +{ + "additionalColumns": [], + "translator": { + "type": "TabularTranslator", + "mappings": [ + { + "source": { + "path": "['id']" + }, + "sink": { + "name": "ResourceId", + "type": "String" + } + }, + { + "source": { + "path": "['name']['value']" + }, + "sink": { + "name": "ResourceName" + } + }, + { + "source": { + "path": "['name']['localizedValue']" + }, + "sink": { + "name": "displayName" + } + }, + { + "source": { + "path": "['currentValue']" + }, + "sink": { + "name": "currentValue" + } + }, + { + "source": { + "path": "['limit']" + }, + "sink": { + "name": "limit" + } + }, + { + "source": { + "path": "['unit']" + }, + "sink": { + "name": "unit" + } + } + ], + "collectionReference": "$['value']", + "mapComplexValuesToString": true + } +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/app.bicep b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/app.bicep index f869eec66..3f4eefffc 100644 --- a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/app.bicep +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/app.bicep @@ -43,15 +43,50 @@ param ingestionQueries IngestionQueriesMetadata //============================================================================== // -// Query file entries are generated during build by Build-HubIngestionQueries.ps1. -// Do not edit this section manually. The build script scans the queries/ folder and -// generates loadTextContent entries grouped by the optional "group" field in each JSON file. -var queryFiles = {} +// Load query files -- core recommendations are always included +var coreQueryFiles = { + 'Recommendations-Microsoft-AdvisorCost': loadTextContent('queries/Recommendations-Microsoft-AdvisorCost.json') + 'Recommendations-Microsoft-BackendlessAppGateways': loadTextContent('queries/Recommendations-Microsoft-BackendlessAppGateways.json') + 'Recommendations-Microsoft-BackendlessLoadBalancers': loadTextContent('queries/Recommendations-Microsoft-BackendlessLoadBalancers.json') + 'Recommendations-Microsoft-BasicLoadBalancers': loadTextContent('queries/Recommendations-Microsoft-BasicLoadBalancers.json') + 'Recommendations-Microsoft-BasicPublicIPs': loadTextContent('queries/Recommendations-Microsoft-BasicPublicIPs.json') + 'Recommendations-Microsoft-ClassicAppGateways': loadTextContent('queries/Recommendations-Microsoft-ClassicAppGateways.json') + 'Recommendations-Microsoft-EmptyAppServicePlans': loadTextContent('queries/Recommendations-Microsoft-EmptyAppServicePlans.json') + 'Recommendations-Microsoft-EmptyNSGs': loadTextContent('queries/Recommendations-Microsoft-EmptyNSGs.json') + 'Recommendations-Microsoft-EmptySQLElasticPools': loadTextContent('queries/Recommendations-Microsoft-EmptySQLElasticPools.json') + 'Recommendations-Microsoft-IdleVNetGateways': loadTextContent('queries/Recommendations-Microsoft-IdleVNetGateways.json') + 'Recommendations-Microsoft-LegacyStorageAccounts': loadTextContent('queries/Recommendations-Microsoft-LegacyStorageAccounts.json') + 'Recommendations-Microsoft-OrphanedNATGateways': loadTextContent('queries/Recommendations-Microsoft-OrphanedNATGateways.json') + 'Recommendations-Microsoft-PremiumSnapshots': loadTextContent('queries/Recommendations-Microsoft-PremiumSnapshots.json') + 'Recommendations-Microsoft-SavingsPlan-P1Y': loadTextContent('queries/Recommendations-Microsoft-SavingsPlan-P1Y.json') + 'Recommendations-Microsoft-SavingsPlan-P3Y': loadTextContent('queries/Recommendations-Microsoft-SavingsPlan-P3Y.json') + 'Recommendations-Microsoft-StoppedVMs': loadTextContent('queries/Recommendations-Microsoft-StoppedVMs.json') + 'Recommendations-Microsoft-UnassociatedDDoSPlans': loadTextContent('queries/Recommendations-Microsoft-UnassociatedDDoSPlans.json') + 'Recommendations-Microsoft-UnattachedDisks': loadTextContent('queries/Recommendations-Microsoft-UnattachedDisks.json') + 'Recommendations-Microsoft-UnattachedNICs': loadTextContent('queries/Recommendations-Microsoft-UnattachedNICs.json') + 'Recommendations-Microsoft-UnattachedPublicIPs': loadTextContent('queries/Recommendations-Microsoft-UnattachedPublicIPs.json') + 'Recommendations-Microsoft-UnmanagedDisks': loadTextContent('queries/Recommendations-Microsoft-UnmanagedDisks.json') + 'Recommendations-Microsoft-UnprovisionedExpressRouteCircuits': loadTextContent('queries/Recommendations-Microsoft-UnprovisionedExpressRouteCircuits.json') +} + +// Optional: Azure Hybrid Benefit recommendations (may generate noise without on-premises licenses) +var ahbQueryFiles = enableAHBRecommendations ? { + 'Recommendations-Microsoft-SQLVMsWithoutAHB': loadTextContent('queries/Recommendations-Microsoft-SQLVMsWithoutAHB.json') + 'Recommendations-Microsoft-VMsWithoutAHB': loadTextContent('queries/Recommendations-Microsoft-VMsWithoutAHB.json') +} : {} + +// Optional: Spot VM recommendations (may generate noise for non-interruptible workloads) +var spotQueryFiles = enableSpotRecommendations ? { + 'Recommendations-Microsoft-NonSpotAKSClusters': loadTextContent('queries/Recommendations-Microsoft-NonSpotAKSClusters.json') +} : {} + +var queryFiles = union(coreQueryFiles, ahbQueryFiles, spotQueryFiles) // // Load schema files var schemaFiles = { 'recommendations_1.0': loadTextContent('schemas/recommendations_1.0.json') + 'recommendations_1.1': loadTextContent('schemas/recommendations_1.1.json') } diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SavingsPlan-P1Y.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SavingsPlan-P1Y.json new file mode 100644 index 000000000..b89f35197 --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SavingsPlan-P1Y.json @@ -0,0 +1,10 @@ +{ + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "/providers/Microsoft.CostManagement/benefitRecommendations?api-version=2026-06-01&$filter=properties/lookBackPeriod eq 'Last7Days' and properties/term eq 'P1Y'", + "queryEngine": "AzureResourceManager", + "scope": "Configured", + "source": "Cost Management", + "type": "Microsoft-SavingsPlan-P1Y", + "version": "1.1" +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SavingsPlan-P3Y.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SavingsPlan-P3Y.json new file mode 100644 index 000000000..e7c83a31c --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/queries/Recommendations-Microsoft-SavingsPlan-P3Y.json @@ -0,0 +1,10 @@ +{ + "dataset": "Recommendations", + "provider": "Microsoft", + "query": "/providers/Microsoft.CostManagement/benefitRecommendations?api-version=2026-06-01&$filter=properties/lookBackPeriod eq 'Last7Days' and properties/term eq 'P3Y'", + "queryEngine": "AzureResourceManager", + "scope": "Configured", + "source": "Cost Management", + "type": "Microsoft-SavingsPlan-P3Y", + "version": "1.1" +} diff --git a/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/schemas/recommendations_1.1.json b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/schemas/recommendations_1.1.json new file mode 100644 index 000000000..50522eade --- /dev/null +++ b/src/templates/finops-hub/modules/Microsoft.FinOpsHubs/Recommendations/schemas/recommendations_1.1.json @@ -0,0 +1,91 @@ +{ + "additionalColumns": [], + "translator": { + "type": "TabularTranslator", + "mappings": [ + { + "source": { + "path": "['id']" + }, + "sink": { + "name": "ResourceId", + "type": "String" + } + }, + { + "source": { + "path": "['properties']['armSkuName']" + }, + "sink": { + "name": "ResourceName", + "type": "String" + } + }, + { + "source": { + "path": "['type']" + }, + "sink": { + "name": "ResourceType", + "type": "String" + } + }, + { + "source": { + "path": "['properties']['costWithoutBenefit']" + }, + "sink": { + "name": "x_EffectiveCostBefore", + "type": "Double" + } + }, + { + "source": { + "path": "['properties']['recommendationDetails']['totalCost']" + }, + "sink": { + "name": "x_EffectiveCostAfter", + "type": "Double" + } + }, + { + "source": { + "path": "['properties']['recommendationDetails']['savingsAmount']" + }, + "sink": { + "name": "x_EffectiveCostSavings", + "type": "Double" + } + }, + { + "source": { + "path": "['properties']['firstConsumptionDate']" + }, + "sink": { + "name": "x_RecommendationDate", + "type": "String" + } + }, + { + "source": { + "path": "['properties']" + }, + "sink": { + "name": "x_RecommendationDetails", + "type": "String" + } + }, + { + "source": { + "path": "['kind']" + }, + "sink": { + "name": "x_SourceType", + "type": "String" + } + } + ], + "collectionReference": "$['value']", + "mapComplexValuesToString": true + } +} diff --git a/src/templates/finops-hub/modules/fx/hub-deploymentScript.bicep b/src/templates/finops-hub/modules/fx/hub-deploymentScript.bicep index 95408c527..accfa00fa 100644 --- a/src/templates/finops-hub/modules/fx/hub-deploymentScript.bicep +++ b/src/templates/finops-hub/modules/fx/hub-deploymentScript.bicep @@ -31,6 +31,9 @@ param arguments string = '' @description('Optional. Environment variables to use for the deployment script.') param environmentVariables EnvironmentVariable[] = [] +@description('Optional. Forces the deployment script to run again when redeployed.') +param forceUpdateTag string = utcNow() + //============================================================================== // Variables @@ -114,6 +117,7 @@ resource script 'Microsoft.Resources/deploymentScripts@2023-08-01' = { azPowerShellVersion: '11.0' retentionInterval: 'PT1H' cleanupPreference: 'OnSuccess' + forceUpdateTag: forceUpdateTag scriptContent: scriptContent arguments: arguments environmentVariables: environmentVariables diff --git a/src/templates/finops-hub/modules/hub.bicep b/src/templates/finops-hub/modules/hub.bicep index 0b18ee35e..addd7c0c2 100644 --- a/src/templates/finops-hub/modules/hub.bicep +++ b/src/templates/finops-hub/modules/hub.bicep @@ -50,6 +50,9 @@ param enableManagedExports bool = true @description('Optional. Enable recommendations ingested from Azure Resource Graph based on configurable queries. The Data Factory managed identity requires Reader role on management groups or subscriptions to execute Resource Graph queries. Default: false.') param enableRecommendations bool = false +@description('Optional. Enable quota and capacity data ingestion from Azure Resource Manager. The Data Factory managed identity requires Reader role on the subscriptions to scan. Default: false.') +param enableQuota bool = false + @description('Optional. Enable Azure Hybrid Benefit recommendations that flag VMs and SQL VMs without Azure Hybrid Benefit enabled. May generate noise if your organization does not have on-premises licenses. Requires enableRecommendations. Default: false.') param enableAHBRecommendations bool = false @@ -318,7 +321,7 @@ module analytics 'Microsoft.FinOpsHubs/Analytics/app.bicep' = if (useFabric || u // Ingestion queries //------------------------------------------------------------------------------ -module ingestionQueries 'Microsoft.FinOpsHubs/IngestionQueries/app.bicep' = if (enableRecommendations) { +module ingestionQueries 'Microsoft.FinOpsHubs/IngestionQueries/app.bicep' = if (enableRecommendations || enableQuota) { name: 'Microsoft.FinOpsHubs.IngestionQueries' params: { app: newApp(hub, 'Microsoft.FinOpsHubs', 'IngestionQueries') @@ -334,6 +337,14 @@ module azureResourceGraph 'Microsoft.FinOpsHubs/AzureResourceGraph/app.bicep' = } } +module azureResourceManager 'Microsoft.FinOpsHubs/AzureResourceManager/app.bicep' = if (enableRecommendations || enableQuota) { + name: 'Microsoft.FinOpsHubs.AzureResourceManager' + params: { + app: newApp(hub, 'Microsoft.FinOpsHubs', 'AzureResourceManager') + core: core.outputs.metadata + } +} + //------------------------------------------------------------------------------ // Custom recommendations //------------------------------------------------------------------------------ @@ -342,6 +353,7 @@ module recommendations 'Microsoft.FinOpsHubs/Recommendations/app.bicep' = if (en name: 'Microsoft.FinOpsHubs.Recommendations' dependsOn: [ azureResourceGraph + azureResourceManager ] params: { app: newApp(hub, 'Microsoft.FinOpsHubs', 'Recommendations') @@ -352,6 +364,18 @@ module recommendations 'Microsoft.FinOpsHubs/Recommendations/app.bicep' = if (en } } +module quota 'Microsoft.FinOpsHubs/Quota/app.bicep' = if (enableQuota) { + name: 'Microsoft.FinOpsHubs.Quota' + dependsOn: [ + azureResourceManager + ] + params: { + app: newApp(hub, 'Microsoft.FinOpsHubs', 'Quota') + core: core.outputs.metadata + ingestionQueries: ingestionQueries!.outputs.metadata // Safe: guarded by the same enableQuota condition + } +} + //------------------------------------------------------------------------------ // Remote hub app //------------------------------------------------------------------------------ @@ -400,6 +424,7 @@ module startTriggers 'fx/hub-initialize.bicep' = { dependsOn: [ analytics recommendations + quota deleteOldResources remoteHub cmManagedExports diff --git a/src/templates/finops-hub/test/main.test.bicep b/src/templates/finops-hub/test/main.test.bicep index 82d7595d9..0821be413 100644 --- a/src/templates/finops-hub/test/main.test.bicep +++ b/src/templates/finops-hub/test/main.test.bicep @@ -6,7 +6,7 @@ targetScope = 'resourceGroup' param uniqueName string = 'ftk-hub-localtest1' param location string = 'westus2' -// Test 1 - Creates a FinOps hub instance with default settings. +// Test 1 - Creates a FinOps hub instance with recommendations and quota disabled. module hub '../main.bicep' = { name: 'finops-hub' params: { @@ -15,4 +15,35 @@ module hub '../main.bicep' = { } } +// Test 2 - Creates a FinOps hub instance with recommendations enabled. +module hubRecommendations '../main.bicep' = { + name: 'finops-hub-recommendations' + params: { + hubName: '${uniqueName}-recommendations' + location: location + enableRecommendations: true + } +} + +// Test 3 - Creates a FinOps hub instance with quota enabled. +module hubQuota '../main.bicep' = { + name: 'finops-hub-quota' + params: { + hubName: '${uniqueName}-quota' + location: location + enableQuota: true + } +} + +// Test 4 - Creates a FinOps hub instance with recommendations and quota enabled. +module hubRecommendationsAndQuota '../main.bicep' = { + name: 'finops-hub-recommendations-quota' + params: { + hubName: '${uniqueName}-recommendations-quota' + location: location + enableRecommendations: true + enableQuota: true + } +} + output hubName string = hub.outputs.name From c5f116ede586f7100728c7cb0d0c0b36547e2f14 Mon Sep 17 00:00:00 2001 From: MSBrett Date: Mon, 24 Aug 2026 08:27:04 -0700 Subject: [PATCH 02/18] feat(dashboard): add AI and emerging workloads view Adds an eighth view to the FinOps hub dashboard canvas extension, porting the AI and emerging workloads page from the Azure Data Explorer dashboard into the extension's own query and rendering pipeline. Query layer (kusto.mjs): - getAi() supplies 16 named queries across estate composition, token and model economics, workload detail, allocation, and rate posture. - Month-over-month comparisons anchor to the last closed month. The newest month in a window is normally still ingesting, so comparing it against a full month reported a spend collapse that was not real. - Token direction uses word-boundary regexes rather than substring matching, so meters such as "Cd Wr Std" classify correctly instead of falling into an Other bucket, and a future "Throughput" meter cannot be read as output. - Owner tags fold case-insensitively while retaining the highest-cost casing for display, so one owner cannot appear as several rows. - Follows repo KQL guidance: no bare joins, and contains reserved for genuinely fused substrings. Rendering (public/): - Six KPI cards, two new chart primitives, and fifteen panels. - Formatters gain fmtRate, fmtQty, fmtShare, moneyColumn and rateColumn. Column helpers hold one precision per column, because a reader compares cells down a column rather than against each value's own magnitude, and mark sub-unit values rather than rounding real spend to zero. - Axis ceilings round up to readable steps and never clip the series. - Every table is wrapped so it scrolls within its panel. An unwrapped table previously forced horizontal scrolling on the whole page at narrow widths. - The active tab is scrolled into view, including on deep links. - Panels are paired by content height so no card renders mostly empty. Also fixes two latent defects reachable from existing views: table swatches were not displayed, and threshold colours were defined only under .kpi so they had no effect inside tables. Verified against a live hub across 1.2M rows, with no horizontal overflow at 1600, 1280, 950, 768 and 480 CSS pixels. Test suite covers the new behaviour and passes 34/34. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extensions/ftk-local-dashboard/PRODUCT.md | 36 + .../extensions/ftk-local-dashboard/README.md | 43 + .../ftk-local-dashboard/extension.mjs | 915 ++++++ .../extensions/ftk-local-dashboard/kusto.mjs | 1874 +++++++++++ .../ftk-local-dashboard/public/app.css | 1080 ++++++ .../ftk-local-dashboard/public/app.js | 2910 +++++++++++++++++ .../ftk-local-dashboard/public/index.html | 97 + .../test/ftk-local-dashboard.test.mjs | 785 +++++ .gitignore | 3 + package.json | 3 +- 10 files changed, 7745 insertions(+), 1 deletion(-) create mode 100644 .github/extensions/ftk-local-dashboard/PRODUCT.md create mode 100644 .github/extensions/ftk-local-dashboard/README.md create mode 100644 .github/extensions/ftk-local-dashboard/extension.mjs create mode 100644 .github/extensions/ftk-local-dashboard/kusto.mjs create mode 100644 .github/extensions/ftk-local-dashboard/public/app.css create mode 100644 .github/extensions/ftk-local-dashboard/public/app.js create mode 100644 .github/extensions/ftk-local-dashboard/public/index.html create mode 100644 .github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs diff --git a/.github/extensions/ftk-local-dashboard/PRODUCT.md b/.github/extensions/ftk-local-dashboard/PRODUCT.md new file mode 100644 index 000000000..85ecd3b71 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/PRODUCT.md @@ -0,0 +1,36 @@ +# Product + +## Register + +product + +## Users + +FinOps practitioners, cloud engineers, and consultants who need to analyze Azure cost data locally — without deploying Azure resources. They run this inside GitHub Copilot as a canvas panel while they work: exploring the data model, validating large datasets, or doing FinOps analysis in disconnected / on-premises environments. They are data-fluent, comfortable with KQL and Azure concepts, and expect density and precision over decoration. They are in a task when they open this — they want numbers fast. + +## Product Purpose + +A FinOps hub dashboard that connects to a local Kusto emulator or a remote Azure Data Explorer cluster. It provides cost, allocation, rate optimization, usage, anomaly, AI token, and capacity views. The Capacity workspace keeps quota, billed demand, inventory, physical supply, and pricing commitments as separate evidence classes. Success means that a practitioner can load hub data, select a view, and find useful evidence in one session. + +## Brand Personality + +Precise. Grounded. Efficient. The interface should feel like a well-calibrated instrument, not a product pitch. Numbers are the hero; the chrome disappears. + +## Anti-references + +- Consumer personal finance dashboards (Mint, Copilot Money) — too soft, too colorful +- SaaS marketing dashboards (hero metric templates, gradient text, glassmorphism cards) +- Over-designed BI tools with heavy chrome, deep sidebars, and modal-heavy workflows +- Any interface that prioritizes looking impressive over being immediately useful + +## Design Principles + +1. **Numbers first** — KPIs and data are the primary visual element. Supporting chrome (headers, tabs, labels) recedes. +2. **GitHub-native** — Use GitHub design tokens (`--background-color-default`, `--text-color-default`, etc.) so the panel feels like an extension of Copilot, not a foreign app. +3. **Density is a virtue** — FinOps data is inherently multi-dimensional. Don't sacrifice information density for whitespace. +4. **State is explicit** — Loading, error, empty, and no-data states are real states, not afterthoughts. Every panel handles all of them. +5. **Zero ceremony** — No animated intros, no onboarding tours. Open panel → see data. + +## Accessibility & Inclusion + +WCAG AA minimum. SVG charts include `` elements for screen-reader context. Interactive controls have ARIA roles and labels. Capacity heatmaps include values and states as text. Users can operate the tabs with a keyboard. The interface respects reduced-motion preferences. diff --git a/.github/extensions/ftk-local-dashboard/README.md b/.github/extensions/ftk-local-dashboard/README.md new file mode 100644 index 000000000..fcfd4a460 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/README.md @@ -0,0 +1,43 @@ +# FinOps hub dashboard canvas + +The FinOps hub dashboard is a repository-scoped GitHub Copilot canvas. It connects to a local Kusto emulator or a remote Azure Data Explorer cluster. + +The dashboard includes these views: + +- Cost overview +- Allocation +- Rate optimization +- Usage and unit economics +- Anomalies and forecast +- AI tokenomics +- AI and emerging workloads +- Capacity +- Read-only KQL query editor + +## Capacity evidence + +The Capacity workspace shows seven evidence classes: App Service, Azure AI, Compute, Azure SQL, Storage, capacity reservations, and Premium SSD v2. + +The workspace doesn't combine unlike evidence into one score. It keeps these concepts separate: + +- Provider quota entitlement +- Billed demand +- Observed resource inventory +- Physical Azure capacity +- Pricing commitments + +Only registered Compute metrics support quota utilization and headroom calculations. Unknown metrics remain visible as descriptive evidence. Stale or invalid rows don't receive quota-health calculations. + +## Run the canvas + +Reload GitHub Copilot extensions after you change the source. The repository-scoped extension must report `sourceScope: "project"` from the `get_build_info` action. + +The installed user extension uses `http://127.0.0.1:47821/`. The repository-scoped extension uses `http://127.0.0.1:47822/` so both sources can run during development. Connection preferences remain in the user's Copilot extension artifacts directory and aren't stored in the repository. + +## Test the canvas + +Run the dependency-free test suite: + +```console +npm run test-dashboard +``` diff --git a/.github/extensions/ftk-local-dashboard/extension.mjs b/.github/extensions/ftk-local-dashboard/extension.mjs new file mode 100644 index 000000000..33d0166aa --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/extension.mjs @@ -0,0 +1,915 @@ +// Extension: ftk-local-dashboard +// A FinOps dashboard canvas for local and remote FinOps hubs. +// +// open() boots a per-instance loopback HTTP server that serves the static +// dashboard (public/) and JSON endpoints for configuration, shared canvas +// state, dashboard views, and read-only KQL. +// The dashboard renderer fetches /api/dashboard, which runs the FinOps query +// layer (kusto.mjs) against the selected Hub database. + +import { createServer } from "node:http"; +import { readFile, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + runQuery, + getDashboard, + getTokenomics, + getAllocation, + getRate, + getUsage, + getAnomaly, + getCapacity, + getAi, + normalizeConnection, + normalizeCapacityClassId, + validateFilters, + ALLOWED_FILTER_COLUMNS, + CAPACITY_CLASS_REGISTRY, +} from "./kusto.mjs"; + +const TEST_MODE = process.env.FTK_LOCAL_DASHBOARD_TEST === "1"; +let joinSession, createCanvas, CanvasError; +if (TEST_MODE) { + createCanvas = (definition) => definition; + CanvasError = class extends Error { + constructor(code, message) { + super(message); + this.code = code; + } + }; +} else { + ({ joinSession, createCanvas, CanvasError } = await import("@github/copilot-sdk/extension")); +} + +const GETTERS = { + overview: getDashboard, + tokenomics: getTokenomics, + ai: getAi, + allocation: getAllocation, + rate: getRate, + usage: getUsage, + anomaly: getAnomaly, + capacity: getCapacity, +}; + +const PUBLIC_DIR = new URL("./public/", import.meta.url); +const HARDCODED_CLUSTER = "http://localhost:8082"; +const HARDCODED_DB = "Hub"; +const VALID_PRESETS = ["all", "12m", "6m", "3m"]; +const DASHBOARD_TABS = Object.keys(GETTERS); +const VALID_TABS = [...DASHBOARD_TABS, "monaco"]; +const QUERY_MAX_LENGTH = 65536; +const QUERY_ROW_LIMIT = 500; +const REQUEST_BODY_LIMIT = 128 * 1024; +const BUILD_ID = "ftk-local-dashboard-capacity-v1"; +const SOURCE_SCOPE = import.meta.url.includes("/.github/extensions/") ? "project" : "user"; + +// Use stable, scope-specific ports so the project source can run beside an +// installed user copy without falling back to a changing ephemeral URL. +const DEFAULT_DASHBOARD_PORT = SOURCE_SCOPE === "project" ? 47822 : 47821; +const DASHBOARD_PORT = Number(process.env.FTK_LOCAL_DASHBOARD_PORT) || DEFAULT_DASHBOARD_PORT; + +// Per-user preference file: the selected hub/database is a user choice, not a +// repo-wide constant, so it's remembered across sessions instead of hardcoded. +// See create-canvas skill's +// "State model" — per-user preference, not per-session/instance. +const CONFIG_DIR = join(process.env.COPILOT_HOME || join(homedir(), ".copilot"), "extensions", "ftk-local-dashboard", "artifacts"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +async function loadPersistedConfig() { + try { + const raw = await readFile(CONFIG_FILE, "utf8"); + const parsed = JSON.parse(raw); + return { + clusterUri: typeof parsed.clusterUri === "string" ? parsed.clusterUri : undefined, + database: typeof parsed.database === "string" ? parsed.database : undefined, + lastQuery: typeof parsed.lastQuery === "string" ? parsed.lastQuery : undefined, + }; + } catch { + return {}; + } +} + +// Merges `patch` onto whatever is currently on disk instead of overwriting the +// whole file, so saving the query editor's text can't clobber the persisted +// clusterUri/database (and vice versa) -- the two are updated independently +// and on different cadences (query text on every edit; connection on Settings +// dialog submit). Writes are serialized onto a single chained promise: two +// concurrent callers (e.g. a Settings-dialog POST landing while the query +// editor's debounced autosave is also writing) both read-modify-write the +// same file, and without serialization the second writer's stale read can +// silently discard the first writer's change. Chaining forces each write to +// see the previous one's result. +let configWriteChain = Promise.resolve(); + +async function savePersistedConfig(patch) { + configWriteChain = configWriteChain.catch(() => {}).then(async () => { + await mkdir(CONFIG_DIR, { recursive: true }); + const current = await loadPersistedConfig(); + const tempFile = `${CONFIG_FILE}.${process.pid}.tmp`; + try { + await writeFile(tempFile, JSON.stringify({ ...current, ...patch }, null, 2)); + await rename(tempFile, CONFIG_FILE); + } catch (err) { + await rm(tempFile, { force: true }).catch(() => {}); + throw err; + } + }); + return configWriteChain; +} + +const persisted = TEST_MODE ? {} : await loadPersistedConfig(); + +// Resolution order: remembered last choice (highest, once anything has ever +// been persisted) > explicit `open` input > FTK_LOCAL_CLUSTER_URI/ +// FTK_LOCAL_DATABASE env vars > hardcoded fallback. +// +// This used to put `open` input first, on the theory that a later open() +// call carrying input meant "the Settings dialog reopened this canvas with +// a new connection." That's wrong: the SDK's actual open() wire type +// (CanvasProviderOpenRequest, generated/rpc.d.ts) carries no `reason` field, +// so extension code cannot tell a genuine user-driven reopen apart from the +// host silently replaying the *original, creation-time* input on one of its +// frequent restart-driven rehydrates (see DASHBOARD_PORT comment above). +// Once a real connection has ever been persisted via POST /api/config (the +// only channel the Settings dialog actually uses -- see public/app.js), it +// must always win, or every host restart silently reverts the user's +// deliberate choice back to whatever input the panel first opened with. +const DEFAULT_CLUSTER = persisted.clusterUri || process.env.FTK_LOCAL_CLUSTER_URI || HARDCODED_CLUSTER; +const DEFAULT_DB = persisted.database || process.env.FTK_LOCAL_DATABASE || HARDCODED_DB; +const DEFAULT_QUERY = "Costs\n| take 20"; + +const STATIC = { + "/": ["index.html", "text/html; charset=utf-8"], + "/index.html": ["index.html", "text/html; charset=utf-8"], + "/app.css": ["app.css", "text/css; charset=utf-8"], + "/app.js": ["app.js", "application/javascript; charset=utf-8"], +}; + +// This canvas has no legitimate multi-instance use case -- it's one live +// dashboard onto one Kusto emulator. Per-instance servers (keyed by +// caller-supplied instanceId) meant any duplicate panel -- whether from the +// host reopening under a new id, or an agent mistakenly inventing one -- +// spun up its own ephemeral port with independently diverging state +// (connection settings, in-progress query text). A singleton removes the +// possibility entirely: every open(), regardless of instanceId, resolves to +// the same server/port/state, so duplicate panels can never diverge. +let singleton = null; // { server, url, clusterUri, database, lastQuery, canvasState, openInstances: Set<string> } + +async function getOrCreateSingleton(clusterUri, database) { + if (!singleton) { + const connection = normalizeConnection(clusterUri, database); + singleton = { + clusterUri: connection.clusterUri, + database: connection.database, + lastQuery: persisted.lastQuery ?? DEFAULT_QUERY, + canvasState: { + tab: "overview", + preset: "all", + filters: {}, + capacityClass: "home", + capacitySelections: {}, + revision: Date.now(), + }, + openInstances: new Set(), + }; + await startServer(singleton); + } + return singleton; +} + +/** Parse and validate `?filters=<JSON>` from a URL search params. */ +function parseFilters(url) { + const raw = url.searchParams.get("filters"); + if (!raw) return {}; + try { + return validateFilters(JSON.parse(raw)); + } catch (err) { + throw new Error(`Invalid filters: ${err.message}`); + } +} + +function connectionInfo(entry) { + const normalized = normalizeConnection(entry.clusterUri, entry.database); + return { + clusterUri: normalized.clusterUri, + database: normalized.database, + mode: normalized.mode, + authentication: normalized.authentication, + }; +} + +export function getBuildInfo() { + return { buildId: BUILD_ID, sourceScope: SOURCE_SCOPE }; +} + +const CAPACITY_SELECTION_FIELDS = Object.freeze({ + quotaSelection: new Set(["subAccountId", "location", "resourceName", "unit", "sourceVersion", "resourceId"]), + metricSelection: new Set(["resourceName", "unit", "sourceVersion"]), + demandSelection: new Set([ + "meterCategory", + "meterSubcategory", + "meter", + "priceId", + "unit", + "currency", + "resourceId", + "capacityReservationId", + "capacityReservationStatus", + ]), +}); + +export function validateCapacitySelections(input = {}) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("Capacity selections must be an object."); + } + const normalized = {}; + for (const [selectionName, selection] of Object.entries(input)) { + const allowedFields = CAPACITY_SELECTION_FIELDS[selectionName]; + if (!allowedFields) throw new Error(`Unsupported capacity selection '${selectionName}'.`); + if (!selection || typeof selection !== "object" || Array.isArray(selection)) { + throw new Error(`Capacity selection '${selectionName}' must be an object.`); + } + const clean = {}; + for (const [field, value] of Object.entries(selection)) { + if (!allowedFields.has(field)) throw new Error(`Unsupported ${selectionName} field '${field}'.`); + if (typeof value !== "string" || !value.trim() || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value)) { + throw new Error(`${selectionName}.${field} must be 1-512 printable characters.`); + } + clean[field] = value.trim(); + } + normalized[selectionName] = clean; + } + return normalized; +} + +export function validateCanvasStatePatch(input = {}) { + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Canvas state must be an object."); + const allowed = new Set(["tab", "preset", "filters", "capacityClass", "capacitySelections", "expectedRevision"]); + for (const key of Object.keys(input)) { + if (!allowed.has(key)) throw new Error(`Unsupported canvas state property '${key}'.`); + } + const patch = {}; + if ("tab" in input) { + if (!VALID_TABS.includes(input.tab)) throw new Error(`Unknown canvas tab '${input.tab}'.`); + patch.tab = input.tab; + } + if ("preset" in input) { + if (!VALID_PRESETS.includes(input.preset)) throw new Error(`Unknown time preset '${input.preset}'.`); + patch.preset = input.preset; + } + if ("filters" in input) patch.filters = validateFilters(input.filters); + if ("capacityClass" in input) patch.capacityClass = normalizeCapacityClassId(input.capacityClass); + if ("capacitySelections" in input) patch.capacitySelections = validateCapacitySelections(input.capacitySelections); + if ("expectedRevision" in input && (!Number.isInteger(input.expectedRevision) || input.expectedRevision < 0)) { + throw new Error("expectedRevision must be a non-negative integer."); + } + return { patch, expectedRevision: input.expectedRevision }; +} + +export function updateCanvasState(current, input = {}) { + const { patch, expectedRevision } = validateCanvasStatePatch(input); + if (expectedRevision !== undefined && expectedRevision !== current.revision) { + const err = new Error("Canvas state changed before this update."); + err.code = "revision_conflict"; + err.state = current; + throw err; + } + return { ...current, ...patch, revision: current.revision + 1 }; +} + +function queryStructure(kql) { + let output = ""; + let state = "code"; + for (let i = 0; i < kql.length; i++) { + const char = kql[i], next = kql[i + 1]; + if (state === "line") { + if (char === "\n") { state = "code"; output += "\n"; } else output += " "; + } else if (state === "block") { + if (char === "*" && next === "/") { output += " "; i++; state = "code"; } + else output += char === "\n" ? "\n" : " "; + } else if (state === "single" || state === "double") { + const quote = state === "single" ? "'" : '"'; + if (char === "\\") { output += " "; i++; } + else if (char === quote) { output += " "; state = "code"; } + else output += char === "\n" ? "\n" : " "; + } else if (char === "/" && next === "/") { + output += " "; i++; state = "line"; + } else if (char === "/" && next === "*") { + output += " "; i++; state = "block"; + } else if (char === "'" || char === '"') { + output += " "; state = char === "'" ? "single" : "double"; + } else { + output += char; + } + } + return output; +} + +export function validateReadOnlyQuery(kql) { + if (typeof kql !== "string" || !kql.trim()) throw new Error("KQL is required."); + if (kql.length > QUERY_MAX_LENGTH) throw new Error(`KQL must be at most ${QUERY_MAX_LENGTH} characters.`); + const structure = queryStructure(kql); + for (const match of structure.matchAll(/(?:^|[;\n])\s*\.(\w+)/gim)) { + if (match[1].toLowerCase() !== "show") throw new Error(`Management command '.${match[1]}' is not allowed.`); + } + return kql.trim(); +} + +async function readJsonBody(req) { + let body = ""; + for await (const chunk of req) { + body += chunk; + if (Buffer.byteLength(body) > REQUEST_BODY_LIMIT) throw new Error("Request body is too large."); + } + try { + return JSON.parse(body || "{}"); + } catch { + throw new Error("Invalid JSON."); + } +} + +export async function changeConnection(entry, input, dependencies = {}) { + const query = dependencies.runQueryFn || runQuery; + const persist = dependencies.persistConfig || savePersistedConfig; + const next = normalizeConnection(input?.clusterUri, input?.database || "Hub"); + await query(next.clusterUri, next.database, "Costs() | take 0"); + await persist({ clusterUri: next.clusterUri, database: next.database }); + entry.clusterUri = next.clusterUri; + entry.database = next.database; + entry.canvasState = { ...entry.canvasState, revision: entry.canvasState.revision + 1 }; + return connectionInfo(entry); +} + +export function validateLoopbackRequest(entry, req, path) { + if (!entry.url) return { status: 503, error: "Dashboard server is starting." }; + const expected = new URL(entry.url); + const host = String(req.headers.host || "").toLowerCase(); + if (host !== expected.host.toLowerCase()) { + return { status: 403, error: "Request host is not allowed." }; + } + const origin = req.headers.origin; + if (origin && origin !== expected.origin) { + return { status: 403, error: "Request origin is not allowed." }; + } + if (req.headers["sec-fetch-site"] === "cross-site") { + return { status: 403, error: "Cross-site requests are not allowed." }; + } + if (path.startsWith("/api/") && req.method === "POST") { + const contentType = String(req.headers["content-type"] || ""); + if (!/^application\/json(?:\s*;|$)/i.test(contentType)) { + return { status: 415, error: "POST requests require application/json." }; + } + } + return null; +} + +export function validateViewInput(input = {}) { + const name = input.name || "overview"; + const preset = input.preset || "all"; + if (!DASHBOARD_TABS.includes(name)) throw new Error(`Unknown view '${name}'.`); + if (!VALID_PRESETS.includes(preset)) throw new Error(`Unknown time preset '${preset}'.`); + const capacityClass = normalizeCapacityClassId(input.capacityClass || "home"); + const capacitySelections = validateCapacitySelections(input.capacitySelections || {}); + return { name, preset, filters: validateFilters(input.filters || {}), capacityClass, capacitySelections }; +} + +function sendJson(res, status, obj) { + const body = JSON.stringify(obj); + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); + res.end(body); +} + +function logError(context, err) { + console.error("[ftk-local-dashboard]", context, err); +} + +function sendQueryError(res, entry, viewName, err) { + logError(`Could not query ${viewName} for ${entry.clusterUri}/${entry.database}`, err); + sendJson(res, 200, { + error: err?.message || "Could not query the FinOps hub. Check the extension logs for details.", + clusterUri: entry.clusterUri, + database: entry.database, + }); +} + +async function handleRequest(entry, req, res) { + const url = new URL(req.url, "http://127.0.0.1"); + const path = url.pathname; + const policyError = validateLoopbackRequest(entry, req, path); + if (policyError) { + sendJson(res, policyError.status, { error: policyError.error }); + return; + } + + if (STATIC[path]) { + const [file, type] = STATIC[path]; + try { + const buf = await readFile(new URL(file, PUBLIC_DIR)); + res.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); + res.end(buf); + } catch (err) { + logError(`Could not serve asset ${file}`, err); + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Asset error"); + } + return; + } + + if (path === "/api/config" && req.method === "GET") { + sendJson(res, 200, { + ...connectionInfo(entry), + lastQuery: entry.lastQuery ?? DEFAULT_QUERY, + }); + return; + } + + // Probe and persist a new connection before changing the live singleton. + if (path === "/api/config" && req.method === "POST") { + try { + const body = await readJsonBody(req); + const connection = await changeConnection(entry, body); + sendJson(res, 200, connection); + } catch (err) { + logError("Could not change FinOps hub connection", err); + sendJson(res, 400, { error: err.message || "Could not change connection." }); + } + return; + } + + // The query editor tab autosaves its text here (debounced) so that a page + // reload -- whether from a manual refresh or the host restarting this + // extension process (the URL itself is now fixed and survives restarts, + // but the server process, and any unpersisted in-memory state, does not) + // -- never silently discards an in-progress, unrun query. + if (path === "/api/query-state" && req.method === "POST") { + let query; + try { ({ query } = await readJsonBody(req)); } catch (err) { sendJson(res, 400, { error: err.message }); return; } + if (typeof query !== "string") { sendJson(res, 400, { error: "query must be a string" }); return; } + entry.lastQuery = query; + try { + await savePersistedConfig({ lastQuery: query }); + sendJson(res, 200, { ok: true }); + } catch (err) { + logError("Could not persist query text", err); + sendJson(res, 200, { ok: false }); + } + return; + } + + if (path === "/api/session-state" && req.method === "GET") { + sendJson(res, 200, entry.canvasState); + return; + } + + if (path === "/api/session-state" && req.method === "POST") { + try { + entry.canvasState = updateCanvasState(entry.canvasState, await readJsonBody(req)); + sendJson(res, 200, entry.canvasState); + } catch (err) { + if (err.code === "revision_conflict") { + sendJson(res, 409, { error: err.code, state: err.state }); + } else { + sendJson(res, 400, { error: err.message || "Invalid canvas state." }); + } + } + return; + } + + if (path === "/api/config") { + res.writeHead(405, { "Content-Type": "text/plain" }); + res.end("Method not allowed"); + return; + } + + // Database schema for the experimental Monaco KQL tab's autocomplete + // (monaco-kusto's worker.setSchemaFromShowSchema expects the parsed + // `.show schema as json` object, not generic query rows). + if (path === "/api/schema") { + try { + const rows = await runQuery(entry.clusterUri, entry.database, ".show schema as json"); + const cell = rows[0] ? Object.values(rows[0])[0] : null; + const schema = typeof cell === "string" ? JSON.parse(cell) : cell; + sendJson(res, 200, { schema, clusterUri: entry.clusterUri, database: entry.database }); + } catch (err) { + sendQueryError(res, entry, "schema", err); + } + return; + } + + if (path === "/api/dashboard") { + try { + const preset = url.searchParams.get("preset") || "all"; + if (!VALID_PRESETS.includes(preset)) throw new Error(`Unknown time preset '${preset}'.`); + const filters = parseFilters(url); + const payload = await getDashboard(entry.clusterUri, entry.database, preset, filters); + sendJson(res, 200, payload); + } catch (err) { + sendQueryError(res, entry, "overview", err); + } + return; + } + + if (path === "/api/tokenomics") { + try { + const preset = url.searchParams.get("preset") || "all"; + if (!VALID_PRESETS.includes(preset)) throw new Error(`Unknown time preset '${preset}'.`); + const filters = parseFilters(url); + const payload = await getTokenomics(entry.clusterUri, entry.database, preset, filters); + sendJson(res, 200, payload); + } catch (err) { + sendQueryError(res, entry, "tokenomics", err); + } + return; + } + + if (path === "/api/view" && (req.method === "GET" || req.method === "POST")) { + try { + const input = req.method === "POST" + ? await readJsonBody(req) + : { + name: url.searchParams.get("name") || "overview", + preset: url.searchParams.get("preset") || "all", + filters: parseFilters(url), + }; + const { name, preset, filters, capacityClass, capacitySelections } = validateViewInput(input); + const getter = GETTERS[name]; + const payload = name === "capacity" + ? await getter(entry.clusterUri, entry.database, capacityClass, capacitySelections) + : await getter(entry.clusterUri, entry.database, preset, filters); + sendJson(res, 200, payload); + } catch (err) { + sendQueryError(res, entry, "view", err); + } + return; + } + + if (path === "/api/kql" && req.method === "POST") { + try { + const body = await readJsonBody(req); + if ("database" in body) throw new Error("Change databases through connection settings."); + const kql = validateReadOnlyQuery(body.kql); + entry.lastQuery = kql; + void savePersistedConfig({ lastQuery: kql }).catch((err) => logError("Could not persist query text", err)); + const rows = await runQuery(entry.clusterUri, entry.database, kql); + sendJson(res, 200, { + rows: rows.slice(0, QUERY_ROW_LIMIT), + truncated: rows.length > QUERY_ROW_LIMIT, + rowLimit: QUERY_ROW_LIMIT, + }); + } catch (err) { + logError("Custom KQL error", err); + sendJson(res, 200, { error: err.message || "Query failed" }); + } + return; + } + + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found"); +} + +// Binds to DASHBOARD_PORT so the canvas URL survives host-triggered extension +// restarts. A restarted process's predecessor has already exited by the time +// this runs (the OS reclaims a LISTEN socket's port immediately on process +// exit), so this normally succeeds on the first try; the short retries only +// guard against the rare case of two processes briefly overlapping during +// teardown. Falls back to an OS-assigned ephemeral port as a last resort so +// the dashboard still works (with a non-stable URL) if the fixed port is +// genuinely held by something else. +async function bindServer(server) { + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await new Promise((resolve, reject) => { + const onError = (err) => reject(err); + server.once("error", onError); + server.listen(DASHBOARD_PORT, "127.0.0.1", () => { + server.removeListener("error", onError); + resolve(); + }); + }); + return; + } catch (err) { + if (err.code !== "EADDRINUSE") throw err; + if (attempt === maxAttempts) { + logError(`Fixed port ${DASHBOARD_PORT} unavailable after ${maxAttempts} attempts, falling back to an ephemeral port`, err); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return; + } + await new Promise((r) => setTimeout(r, 150)); + } + } +} + +async function startServer(entry) { + const server = createServer((req, res) => { + handleRequest(entry, req, res).catch((err) => { + logError("Unhandled dashboard request failure", err); + if (res.headersSent) { + res.destroy(); + } else { + sendJson(res, 500, { error: "Unexpected dashboard server error" }); + } + }); + }); + await bindServer(server); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + entry.server = server; + entry.url = `http://127.0.0.1:${port}/`; + return entry; +} + +// Compact headline KPIs for the agent-facing `summary` action. +function headline(payload) { + if (payload.empty) return { empty: true, window: payload.window }; + const d = payload.data; + const s = d.summary?.[0] || {}; + const list = s.List || 0, eff = s.Effective || 0, contracted = s.Contracted || 0; + const tag = Object.fromEntries((d.tagged || []).map((r) => [r._t, r.Cost || 0])); + const tagTotal = (tag.Tagged || 0) + (tag.Untagged || 0); + const price = Object.fromEntries((d.pricing || []).map((r) => [r.PricingCategory, r.Cost || 0])); + const priceTotal = Object.values(price).reduce((a, b) => a + b, 0); + return { + window: payload.window, + effectiveCost: Math.round(eff * 100) / 100, + billedCost: Math.round((s.Billed || 0) * 100) / 100, + totalSavings: Math.round((list - eff) * 100) / 100, + effectiveSavingsRate: list > 0 ? +((list - eff) / list).toFixed(4) : 0, + negotiatedSavings: Math.round((list - contracted) * 100) / 100, + commitmentSavings: Math.round((contracted - eff) * 100) / 100, + untaggedPercent: tagTotal > 0 ? +((tag.Untagged || 0) / tagTotal).toFixed(4) : 0, + commitmentCoverage: priceTotal > 0 ? +((price.Committed || 0) / priceTotal).toFixed(4) : 0, + resources: s.Resources || 0, + services: s.Services || 0, + subscriptions: s.Subscriptions || 0, + regions: s.Regions || 0, + topServices: (d.topServices || []).slice(0, 5).map((r) => ({ name: r.ServiceName, cost: Math.round((r.Cost || 0) * 100) / 100 })), + generatedAt: payload.generatedAt, + }; +} + +// Compact headline token KPIs for the agent-facing `tokenomics` action. +function tokenHeadline(payload) { + if (payload.empty) return { empty: true, window: payload.window }; + const d = payload.data; + const s = d.summary?.[0] || {}; + const tokens = s.Tokens || 0, eff = s.Effective || 0; + const cloud = d.totalCloud?.[0]?.Effective || 0; + const dir = Object.fromEntries((d.direction || []).map((r) => [r.Direction, r])); + const inTok = dir["Input"]?.Tokens || 0; + const cachedTok = dir["Cached input"]?.Tokens || 0; + return { + window: payload.window, + aiTokenCost: Math.round(eff * 100) / 100, + totalTokens: tokens, + blendedCostPerMillionTokens: tokens > 0 ? +((eff / tokens) * 1e6).toFixed(4) : 0, + cachedInputShareOfInputTokens: inTok + cachedTok > 0 ? +(cachedTok / (inTok + cachedTok)).toFixed(4) : 0, + aiShareOfCloudCost: cloud > 0 ? +(eff / cloud).toFixed(4) : 0, + modelCount: s.Models || 0, + directionMix: (d.direction || []).map((r) => ({ direction: r.Direction, tokens: r.Tokens, cost: Math.round((r.Cost || 0) * 100) / 100 })), + topModels: (d.models || []).slice(0, 5).map((r) => ({ + model: r.Model, tokens: r.Tokens, cost: Math.round((r.Cost || 0) * 100) / 100, + costPerMillionTokens: +((r.CostPer1K || 0) * 1000).toFixed(4), + })), + generatedAt: payload.generatedAt, + }; +} + +const FILTER_SCHEMA = { + type: "object", + additionalProperties: false, + properties: Object.fromEntries( + [...ALLOWED_FILTER_COLUMNS].map((name) => [name, { + type: "array", + maxItems: 8, + items: { type: "string", minLength: 1, maxLength: 256 }, + }]) + ), +}; + +const SELECTION_VALUE_SCHEMA = { type: "string", minLength: 1, maxLength: 512 }; +const CAPACITY_SELECTION_SCHEMA = { + type: "object", + additionalProperties: false, + properties: Object.fromEntries( + Object.entries(CAPACITY_SELECTION_FIELDS).map(([name, fields]) => [name, { + type: "object", + additionalProperties: false, + properties: Object.fromEntries([...fields].map((field) => [field, SELECTION_VALUE_SCHEMA])), + }]) + ), +}; + +export function createDashboardCanvas(dependencies = {}) { + const canvasFactory = dependencies.canvasFactory || createCanvas; + const query = dependencies.runQueryFn || runQuery; + const getters = dependencies.getters || GETTERS; + const persist = dependencies.persistConfig || savePersistedConfig; + const getEntry = dependencies.getEntry || (() => singleton); + const requireEntry = () => { + const entry = getEntry(); + if (!entry) throw new CanvasError("canvas_not_open", "Open the FinOps hub dashboard first."); + return entry; + }; + const queryFailure = (context, err) => { + logError(context, err); + throw new CanvasError("query_failed", err?.message || "Could not query the FinOps hub."); + }; + + return canvasFactory({ + id: "ftk-local-dashboard", + displayName: "FinOps hub dashboard", + description: "Live FinOps dashboard for local and remote hubs with cost, allocation, rate, usage, anomaly, AI tokenomics, AI and emerging workload, and capacity evidence views.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + clusterUri: { type: "string", description: `Local loopback or remote Kusto cluster origin. Seeds only the first run before a connection is persisted; defaults to ${HARDCODED_CLUSTER}.` }, + database: { type: "string", description: "Database name. Default Hub." }, + }, + }, + actions: [ + { + name: "get_build_info", + description: "Return the dashboard build identifier and project or user source scope.", + inputSchema: { type: "object", additionalProperties: false }, + handler: async () => getBuildInfo(), + }, + { + name: "get_connection", + description: "Return the shared FinOps hub connection and authentication mode without credentials.", + inputSchema: { type: "object", additionalProperties: false }, + handler: async () => connectionInfo(requireEntry()), + }, + { + name: "set_connection", + description: "Probe and switch the shared FinOps hub connection, then persist it for future sessions.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["clusterUri"], + properties: { + clusterUri: { type: "string" }, + database: { type: "string", default: "Hub" }, + }, + }, + handler: async (ctx) => { + const entry = requireEntry(); + try { + return await changeConnection(entry, ctx.input, { runQueryFn: query, persistConfig: persist }); + } catch (err) { + logError("Could not change FinOps hub connection", err); + throw new CanvasError("connection_failed", err.message || "Could not change connection."); + } + }, + }, + { + name: "get_canvas_state", + description: "Return the visible tab, Capacity class and selectors, time preset, filters, and state revision shared with the open canvas.", + inputSchema: { type: "object", additionalProperties: false }, + handler: async () => ({ ...requireEntry().canvasState }), + }, + { + name: "set_canvas_state", + description: "Change the visible tab, Capacity class or selectors, time preset, or filters with optional revision conflict detection.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + tab: { type: "string", enum: VALID_TABS }, + preset: { type: "string", enum: VALID_PRESETS }, + filters: FILTER_SCHEMA, + capacityClass: { type: "string", enum: ["home", ...Object.keys(CAPACITY_CLASS_REGISTRY)] }, + capacitySelections: CAPACITY_SELECTION_SCHEMA, + expectedRevision: { type: "integer", minimum: 0 }, + }, + }, + handler: async (ctx) => { + const entry = requireEntry(); + try { + entry.canvasState = updateCanvasState(entry.canvasState, ctx.input); + return { ...entry.canvasState }; + } catch (err) { + if (err.code === "revision_conflict") { + throw new CanvasError("revision_conflict", `Canvas state is now at revision ${err.state.revision}.`); + } + throw new CanvasError("invalid_canvas_state", err.message); + } + }, + }, + { + name: "get_view", + description: "Run any dashboard view against the shared connection and return its structured payload.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["view"], + properties: { + view: { type: "string", enum: DASHBOARD_TABS }, + preset: { type: "string", enum: VALID_PRESETS, default: "all" }, + filters: FILTER_SCHEMA, + capacityClass: { type: "string", enum: ["home", ...Object.keys(CAPACITY_CLASS_REGISTRY)], default: "home" }, + capacitySelections: CAPACITY_SELECTION_SCHEMA, + }, + }, + handler: async (ctx) => { + const entry = requireEntry(); + try { + const input = validateViewInput({ + name: ctx.input?.view, + preset: ctx.input?.preset, + filters: ctx.input?.filters, + capacityClass: ctx.input?.capacityClass, + capacitySelections: ctx.input?.capacitySelections, + }); + return input.name === "capacity" + ? await getters[input.name](entry.clusterUri, entry.database, input.capacityClass, input.capacitySelections) + : await getters[input.name](entry.clusterUri, entry.database, input.preset, input.filters); + } catch (err) { + return queryFailure(`Could not query ${ctx.input?.view}`, err); + } + }, + }, + { + name: "run_query", + description: "Run bounded read-only KQL against the shared connection and return up to 500 rows.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["kql"], + properties: { kql: { type: "string", minLength: 1, maxLength: QUERY_MAX_LENGTH } }, + }, + handler: async (ctx) => { + const entry = requireEntry(); + try { + const kql = validateReadOnlyQuery(ctx.input?.kql); + const rows = await query(entry.clusterUri, entry.database, kql); + return { rows: rows.slice(0, QUERY_ROW_LIMIT), truncated: rows.length > QUERY_ROW_LIMIT, rowLimit: QUERY_ROW_LIMIT }; + } catch (err) { + return queryFailure("Could not run custom KQL", err); + } + }, + }, + { + name: "summary", + description: "Return headline FinOps KPIs for a time window from the shared connection.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { preset: { type: "string", enum: VALID_PRESETS, description: "Time window. Default all." } }, + }, + handler: async (ctx) => { + const entry = requireEntry(); + try { + return headline(await getters.overview(entry.clusterUri, entry.database, ctx.input?.preset || "all")); + } catch (err) { + return queryFailure("Could not query summary", err); + } + }, + }, + { + name: "tokenomics", + description: "Return headline AI token-economics KPIs for a time window from the shared connection.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { preset: { type: "string", enum: VALID_PRESETS, description: "Time window. Default all." } }, + }, + handler: async (ctx) => { + const entry = requireEntry(); + try { + return tokenHeadline(await getters.tokenomics(entry.clusterUri, entry.database, ctx.input?.preset || "all")); + } catch (err) { + return queryFailure("Could not query tokenomics", err); + } + }, + }, + ], + open: async (ctx) => { + try { + const clusterUri = persisted.clusterUri || ctx.input?.clusterUri || DEFAULT_CLUSTER; + const database = persisted.database || ctx.input?.database || DEFAULT_DB; + const entry = await getOrCreateSingleton(clusterUri, database); + entry.openInstances.add(ctx.instanceId); + const connection = connectionInfo(entry); + return { + title: "FinOps hub dashboard", + url: entry.url, + status: `${connection.mode} · ${entry.clusterUri} · ${entry.database}`, + }; + } catch (err) { + throw new CanvasError("invalid_connection", err.message || "Could not open the FinOps hub dashboard."); + } + }, + onClose: async (ctx) => { + if (!singleton) return; + singleton.openInstances.delete(ctx.instanceId); + }, + }); +} + +if (!TEST_MODE) { + await joinSession({ canvases: [createDashboardCanvas()] }); +} diff --git a/.github/extensions/ftk-local-dashboard/kusto.mjs b/.github/extensions/ftk-local-dashboard/kusto.mjs new file mode 100644 index 000000000..e34f2dfa0 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/kusto.mjs @@ -0,0 +1,1874 @@ +// KQL query layer for local and remote FinOps hubs. +// +// Talks to the Kusto HTTP API (/v1/rest/query) and parses the v1 +// response shape (Tables[0]) into plain row objects. The dashboard queries are +// grounded in the FinOps Framework domains and the FinOps toolkit query +// catalog (src/queries/INDEX.md, KPI.md, finops-hub-database-guide.md). + +import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const DEFAULT_TIMEOUT_MS = 20000; +const TOKEN_TIMEOUT_MS = 15000; +const TOKEN_MAX_BUFFER = 1024 * 1024; +const TOKEN_REFRESH_SKEW_MS = 2 * 60 * 1000; +export const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +export const ALLOWED_FILTER_COLUMNS = new Set([ + "ServiceName", + "ServiceCategory", + "RegionId", + "x_ResourceGroupName", + "SubAccountName", + "CommitmentDiscountName", + "x_SkuMeterSubcategory", +]); +export const CAPACITY_LIMITS = Object.freeze({ + primaryRows: 250, + selectorKeys: 500, + dailyPoints: 430, + heatmapCells: 500, +}); +export const CAPACITY_FRESHNESS_HOURS = 48; + +const CAPACITY_SOURCE_TYPES = Object.freeze([ + "AppServiceUsage", + "CognitiveServicesUsage", + "ComputeUsage", + "SqlSubscriptionUsage", + "StorageUsage", + "CapacityReservation", + "PremiumSSDv2Disk", +]); + +export const CAPACITY_CLASS_REGISTRY = Object.freeze({ + "app-service": Object.freeze({ + id: "app-service", + sourceType: "AppServiceUsage", + sourceVersions: Object.freeze(["1.0-usage"]), + providerApiVersion: "2024-11-01", + title: "App Service quota", + evidenceClass: "quota", + evidenceLabel: "Provider-reported App Service quota — point-in-time", + emptyLabel: "No App Service quota observations were ingested; this does not mean zero usage or unlimited capacity.", + demandPredicateId: "app-service-cost", + }), + "azure-ai": Object.freeze({ + id: "azure-ai", + sourceType: "CognitiveServicesUsage", + sourceVersions: Object.freeze(["1.0-usage"]), + providerApiVersion: "2023-05-01", + title: "Azure AI quota pools", + evidenceClass: "provider-counter", + evidenceLabel: "Provider-reported Azure AI quota — point-in-time", + emptyLabel: "No Azure AI quota observations were ingested; check query coverage and provider access.", + demandPredicateId: "azure-ai-cost", + }), + compute: Object.freeze({ + id: "compute", + sourceType: "ComputeUsage", + sourceVersions: Object.freeze(["1.0-usage"]), + providerApiVersion: "2024-07-01", + title: "Compute quota", + evidenceClass: "quota", + evidenceLabel: "Provider-reported compute quota — point-in-time", + emptyLabel: "No Compute quota observations were ingested; deployment capacity is unknown.", + demandPredicateId: "compute-cost", + }), + "azure-sql": Object.freeze({ + id: "azure-sql", + sourceType: "SqlSubscriptionUsage", + sourceVersions: Object.freeze(["1.0-sql"]), + providerApiVersion: "2023-08-01", + title: "Azure SQL subscription quota and counters", + evidenceClass: "provider-counter", + evidenceLabel: "Provider-reported SQL quota — point-in-time", + emptyLabel: "No Azure SQL subscription-usage observations were ingested; SQL quota posture is unknown.", + demandPredicateId: "azure-sql-cost", + }), + storage: Object.freeze({ + id: "storage", + sourceType: "StorageUsage", + sourceVersions: Object.freeze(["1.0-usage"]), + providerApiVersion: "2025-06-01", + title: "Storage quotas", + evidenceClass: "quota", + evidenceLabel: "Provider-reported storage quota — point-in-time", + emptyLabel: "No Storage quota evidence for the selected scope and time. Validate ingestion, permissions, supported regions, and source execution.", + demandPredicateId: "storage-cost", + }), + "capacity-reservations": Object.freeze({ + id: "capacity-reservations", + sourceType: "CapacityReservation", + sourceVersions: Object.freeze(["1.0-capacity-reservation"]), + providerApiVersion: "2024-03-01", + title: "Capacity reservation groups", + evidenceClass: "inventory", + evidenceLabel: "Capacity reservation group observed — inventory only", + emptyLabel: "No capacity reservation groups were observed in the latest ingestion window; absence is unverified without complete-snapshot evidence.", + demandPredicateId: "capacity-reservation-cost", + }), + "premium-ssd-v2": Object.freeze({ + id: "premium-ssd-v2", + sourceType: "PremiumSSDv2Disk", + sourceVersions: Object.freeze(["1.0-disk"]), + providerApiVersion: "2024-03-02", + title: "Premium SSD v2 disks", + evidenceClass: "inventory", + evidenceLabel: "Observed Premium SSD v2 provisioned size — GiB inventory; no quota limit", + emptyLabel: "No Premium SSD v2 disks were observed in the latest ingestion window; this is not a disk quota or regional availability conclusion.", + demandPredicateId: "premium-ssd-cost", + }), +}); + +const CAPACITY_CLASS_BY_SOURCE = new Map( + Object.values(CAPACITY_CLASS_REGISTRY).map((entry) => [entry.sourceType.toLowerCase(), entry]) +); + +export const CAPACITY_METRIC_REGISTRY = Object.freeze({ + "computeusage|cores|count": Object.freeze({ + metricRole: "total-regional-vcpu", + direction: "higher-is-worse", + limitMode: "positive-denominator", + zeroLimitMode: "no-entitlement", + historyMode: "quota-series", + heatmapMode: "regional-percent", + evidenceLabel: "Total regional vCPU quota", + }), + "computeusage|lowprioritycores|count": Object.freeze({ + metricRole: "low-priority-vcpu", + direction: "higher-is-worse", + limitMode: "positive-denominator", + zeroLimitMode: "no-entitlement", + historyMode: "quota-series", + heatmapMode: "regional-percent", + evidenceLabel: "Regional low-priority or Spot vCPU quota", + }), + "computeusage|virtualmachines|count": Object.freeze({ + metricRole: "virtual-machine-count", + direction: "higher-is-worse", + limitMode: "positive-denominator", + zeroLimitMode: "no-entitlement", + historyMode: "quota-series", + heatmapMode: "regional-percent", + evidenceLabel: "Regional virtual machine count quota", + }), +}); + +export const CAPACITY_DEMAND_REGISTRY = Object.freeze({ + "app-service": Object.freeze({ + units: Object.freeze(["Hours", "GiB Hours", "GB", "Units/Hour"]), + label: "Billed App Service usage — daily {unit}, grouped by meter; not quota usage", + }), + "azure-ai": Object.freeze({ + units: Object.freeze(["Units", "Seconds", "Minutes", "Hours"]), + label: "Billed Azure AI usage — daily {unit}, meter-specific; not requests, tokens, or quota unless named by the meter", + }), + compute: Object.freeze({ + units: Object.freeze(["Hours", "Units/Hour", "GB", "Units/Month", "Units", "GB/Month"]), + label: "Billed compute usage — {unit}, meter-specific; not peak cores or capacity availability", + }), + "azure-sql": Object.freeze({ + units: Object.freeze(["Units/Day", "Hours", "GB/Month", "Units/Hour", "Units/Month"]), + label: "Billed SQL usage — daily {unit}, exact meter; not quota utilization", + }), + storage: Object.freeze({ + units: Object.freeze(["Units", "Units/Hour", "GB", "GB/Month", "Units/Month"]), + label: "Billed storage demand — daily {unit}, meter-specific; not quota utilization", + }), + "capacity-reservations": Object.freeze({ + units: Object.freeze(["Hours"]), + label: "Capacity-reservation-linked billed hours — accounting status Used/Unused; not allocated or guaranteed capacity", + }), + "premium-ssd-v2": Object.freeze({ + units: Object.freeze([]), + label: "Resource-matched disk effective cost ({currency}) — financial context; usage quantity not classified", + }), +}); + +let cachedToken = null; +let tokenInFlight = null; + +export function normalizeConnection(clusterUri, database = "Hub") { + if (typeof clusterUri !== "string" || !clusterUri.trim()) { + throw new Error("Cluster URI is required."); + } + + let url; + try { + url = new URL(clusterUri.trim()); + } catch { + throw new Error("Cluster URI must be a valid absolute URL."); + } + if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) { + throw new Error("Cluster URI must contain only the cluster origin."); + } + + const hostname = url.hostname.toLowerCase(); + const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + let mode; + if (isLoopback && url.protocol === "http:") { + mode = "local"; + } else if ( + url.protocol === "https:" && + !url.port && + hostname.endsWith(".kusto.windows.net") && + hostname.length > ".kusto.windows.net".length + ) { + mode = "remote"; + } else { + throw new Error("Use loopback HTTP for a local hub or HTTPS for a *.kusto.windows.net cluster."); + } + + const normalizedDatabase = typeof database === "string" ? database.trim() : ""; + if (!normalizedDatabase || normalizedDatabase.length > 256 || /[\u0000-\u001f\u007f]/.test(normalizedDatabase)) { + throw new Error("Database must be a non-empty name of at most 256 characters."); + } + + return { + clusterUri: url.origin, + database: normalizedDatabase, + mode, + authentication: mode === "remote" ? "azure-cli" : "none", + }; +} + +function tokenExpiryMs(value) { + if (typeof value === "number" || /^\d+$/.test(String(value ?? ""))) { + const numeric = Number(value); + return numeric < 1e12 ? numeric * 1000 : numeric; + } + const parsed = Date.parse(String(value ?? "")); + return Number.isFinite(parsed) ? parsed : NaN; +} + +async function acquireAzureCliToken() { + try { + const { stdout } = await execFileAsync( + "az", + ["account", "get-access-token", "--resource", "https://api.kusto.windows.net", "--output", "json"], + { timeout: TOKEN_TIMEOUT_MS, maxBuffer: TOKEN_MAX_BUFFER, windowsHide: true } + ); + return JSON.parse(stdout); + } catch { + throw new Error("Azure CLI could not acquire a Kusto token. Run az login and retry."); + } +} + +async function getRemoteToken(tokenProvider = acquireAzureCliToken) { + const now = Date.now(); + if (cachedToken && cachedToken.expiresOnMs - TOKEN_REFRESH_SKEW_MS > now) { + return cachedToken.accessToken; + } + if (!tokenInFlight) { + tokenInFlight = Promise.resolve() + .then(() => tokenProvider()) + .then((result) => { + const accessToken = result?.accessToken ?? result?.access_token; + const expiresOnMs = tokenExpiryMs(result?.expiresOnMs ?? result?.expires_on); + if (typeof accessToken !== "string" || !accessToken || !Number.isFinite(expiresOnMs) || expiresOnMs <= Date.now()) { + throw new Error("Azure CLI returned an invalid or expired Kusto token."); + } + cachedToken = { accessToken, expiresOnMs }; + return accessToken; + }) + .catch(() => { + cachedToken = null; + throw new Error("Azure CLI could not acquire a Kusto token. Run az login and retry."); + }) + .finally(() => { + tokenInFlight = null; + }); + } + return tokenInFlight; +} + +export function resetKustoAuthForTests() { + cachedToken = null; + tokenInFlight = null; +} + +export async function readBoundedBody(response, maxBytes = MAX_RESPONSE_BYTES) { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error(`Kusto response exceeded the ${maxBytes}-byte limit.`); + } + if (!response.body?.getReader) { + const text = await response.text(); + if (Buffer.byteLength(text) > maxBytes) throw new Error(`Kusto response exceeded the ${maxBytes}-byte limit.`); + return text; + } + + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw new Error(`Kusto response exceeded the ${maxBytes}-byte limit.`); + } + chunks.push(Buffer.from(value)); + } + return Buffer.concat(chunks, total).toString("utf8"); +} + +function rowsFromTable(table) { + if (!table) return []; + const cols = table.Columns.map((c) => c.ColumnName); + return table.Rows.map((r) => Object.fromEntries(cols.map((c, i) => [c, r[i]]))); +} + +export function parseKustoResponse(json) { + if (json?.error || json?.Exceptions || json?.OneApiErrors) { + const msg = json?.error?.["@message"] || JSON.stringify(json).slice(0, 300); + throw new Error(`Kusto query error: ${msg}`); + } + const tables = Array.isArray(json?.Tables) ? json.Tables : []; + const statusTable = tables.find((table) => { + const names = new Set((table.Columns || []).map((column) => column.ColumnName)); + return names.has("Severity") && names.has("StatusCode") && names.has("StatusDescription"); + }); + const failure = rowsFromTable(statusTable).find((row) => Number(row.StatusCode) !== 0 || Number(row.Severity) <= 2); + if (failure) { + throw new Error(`Kusto query failed: ${failure.StatusDescription || `status ${failure.StatusCode}`}`); + } + return rowsFromTable(tables[0]); +} + +/** + * Run a single KQL query against a FinOps hub and return rows as objects. + * Throws on transport error or Kusto error payload. + */ +export async function runQuery(clusterUri, database, csl, options = {}) { + if (typeof options === "number") options = { timeoutMs: options }; + const { + timeoutMs = DEFAULT_TIMEOUT_MS, + fetchImpl = fetch, + tokenProvider = acquireAzureCliToken, + maxResponseBytes = MAX_RESPONSE_BYTES, + } = options; + const connection = normalizeConnection(clusterUri, database); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let res, text; + try { + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + "x-ms-readonly": "true", + "x-ms-client-request-id": `FinOpsToolkit.FtkDashboard;${randomUUID()}`, + }; + if (connection.mode === "remote") { + headers.Authorization = `Bearer ${await getRemoteToken(tokenProvider)}`; + } + res = await fetchImpl(`${connection.clusterUri}/v1/rest/query`, { + method: "POST", + headers, + body: JSON.stringify({ db: connection.database, csl }), + signal: controller.signal, + }); + text = await readBoundedBody(res, maxResponseBytes); + } catch (err) { + if (err?.name === "AbortError") { + throw new Error(`Timed out after ${timeoutMs}ms reaching ${connection.clusterUri}`); + } + if (/Azure CLI|Kusto token|Kusto response exceeded/.test(err?.message || "")) throw err; + throw new Error(`Could not reach Kusto at ${connection.clusterUri}: ${err?.message ?? err}`); + } finally { + clearTimeout(timer); + } + + if (!res.ok) { + throw new Error(`Kusto returned HTTP ${res.status}. ${text.slice(0, 300)}`); + } + + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error("Kusto returned an invalid JSON response."); + } + return parseKustoResponse(json); +} + +// --- date helpers (work in UTC to match Kusto datetimes) ---------------------- + +function startOfMonthUTC(d) { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); +} +function addMonthsUTC(d, n) { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1)); +} +function isoDay(d) { + return d.toISOString().slice(0, 10); +} + +/** + * Resolve a preset window (all | 12m | 6m | 3m) against the actual data range. + * Returns inclusive start and exclusive end ISO-day strings plus the data range. + */ +export async function resolveWindow(clusterUri, database, preset) { + const range = await runQuery( + clusterUri, + database, + "Costs() | summarize MinDate=min(ChargePeriodStart), MaxDate=max(ChargePeriodStart), Rows=count()" + ); + const row = range[0] ?? {}; + if (!row.MaxDate) { + return { start: null, end: null, dataMin: null, dataMax: null, rows: 0, empty: true }; + } + const dataMin = new Date(row.MinDate); + const dataMax = new Date(row.MaxDate); + const endExclusive = addMonthsUTC(startOfMonthUTC(dataMax), 1); // include the whole last month + const lastMonth = startOfMonthUTC(dataMax); + + let start; + switch (preset) { + case "3m": start = addMonthsUTC(lastMonth, -2); break; + case "6m": start = addMonthsUTC(lastMonth, -5); break; + case "12m": start = addMonthsUTC(lastMonth, -11); break; + case "all": + default: start = startOfMonthUTC(dataMin); break; + } + if (start < startOfMonthUTC(dataMin)) start = startOfMonthUTC(dataMin); + + return { + start: isoDay(start), + end: isoDay(endExclusive), + dataMin: isoDay(dataMin), + dataMax: isoDay(dataMax), + rows: row.Rows ?? 0, + empty: false, + }; +} + +/** + * Build a KQL `| where` clause from a filters object `{ ColumnName: ["val1","val2"] }`. + * Returns an empty string when there are no filters. + */ +export function validateFilters(filters = {}) { + if (!filters || typeof filters !== "object" || Array.isArray(filters)) throw new Error("Filters must be an object."); + const normalized = {}; + for (const [column, values] of Object.entries(filters)) { + if (!ALLOWED_FILTER_COLUMNS.has(column)) throw new Error(`Unsupported filter dimension '${column}'.`); + if (!Array.isArray(values) || values.length > 8) throw new Error(`Filter '${column}' must contain at most 8 values.`); + const clean = [...new Set(values.map((value) => String(value)))]; + if (clean.some((value) => !value || value.length > 256)) { + throw new Error(`Filter '${column}' values must be 1-256 characters.`); + } + if (clean.length) normalized[column] = clean; + } + return normalized; +} + +export function buildFilterWhere(filters) { + const clauses = Object.entries(validateFilters(filters)) + .map(([col, vals]) => { + const quoted = vals.map((value) => JSON.stringify(value)).join(", "); + return `| where ${col} in (${quoted})`; + }); + return clauses.length > 0 ? "\n" + clauses.join("\n") : ""; +} + +function normalizeCapacityValue(value) { + return String(value ?? "").trim().toLowerCase(); +} + +function finiteNumber(value) { + if (value === null || value === undefined || value === "") return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +export function normalizeCapacityClassId(value) { + const normalized = normalizeCapacityValue(value); + if (normalized === "home") return "home"; + if (CAPACITY_CLASS_REGISTRY[normalized]) return normalized; + const sourceMatch = CAPACITY_CLASS_BY_SOURCE.get(normalized); + if (sourceMatch) return sourceMatch.id; + throw new Error(`Unsupported capacity class '${value}'.`); +} + +export function resolveCapacityMetric(row = {}) { + const sourceType = String(row.x_SourceType ?? "").trim(); + const sourceVersion = String(row.x_SourceVersion ?? "").trim(); + const resourceName = String(row.ResourceName ?? "").trim(); + const unit = String(row.unit ?? "").trim(); + const classContract = CAPACITY_CLASS_BY_SOURCE.get(sourceType.toLowerCase()); + + if (!sourceType || !sourceVersion || !classContract) { + return { + capability: "disabled", + reasonCode: classContract ? "invalid-identity" : "unsupported-source", + evidenceLabel: "Required source identity is missing or unsupported", + classId: classContract?.id ?? null, + }; + } + if (!classContract.sourceVersions.some((version) => version.toLowerCase() === sourceVersion.toLowerCase())) { + return { + capability: "descriptive-only", + reasonCode: "source-version-mismatch", + evidenceLabel: "Source version changed — registry review required", + classId: classContract.id, + }; + } + if (classContract.evidenceClass === "inventory") { + if (!resourceName || !String(row.ResourceId ?? "").trim()) { + return { + capability: "disabled", + reasonCode: "invalid-identity", + evidenceLabel: "Inventory identity is incomplete", + classId: classContract.id, + }; + } + return { + capability: "enabled", + reasonCode: "inventory-source-contract", + evidenceLabel: classContract.evidenceLabel, + classId: classContract.id, + evidenceClass: "inventory", + }; + } + if (!resourceName || !unit) { + return { + capability: "disabled", + reasonCode: "invalid-identity", + evidenceLabel: "Quota metric identity is incomplete", + classId: classContract.id, + }; + } + + const metricKey = [sourceType, resourceName, unit].map(normalizeCapacityValue).join("|"); + const metric = CAPACITY_METRIC_REGISTRY[metricKey]; + if (!metric) { + const negativeSqlLimit = + classContract.id === "azure-sql" && finiteNumber(row.limit) !== null && finiteNumber(row.limit) < 0; + return { + capability: "descriptive-only", + reasonCode: "unclassified-metric", + evidenceLabel: negativeSqlLimit + ? "Negative provider limit — interpretation unverified" + : "Registry review required", + classId: classContract.id, + metricKey, + }; + } + return { + capability: "enabled", + reasonCode: "registered-metric", + evidenceLabel: metric.evidenceLabel, + classId: classContract.id, + metricKey, + ...metric, + }; +} + +export function classifyCapacityObservation(row = {}, now = new Date(), freshnessHours = CAPACITY_FRESHNESS_HOURS) { + const semantic = resolveCapacityMetric(row); + const classContract = semantic.classId ? CAPACITY_CLASS_REGISTRY[semantic.classId] : null; + const currentValue = finiteNumber(row.currentValue); + const limit = finiteNumber(row.limit); + const observedAt = Date.parse(String(row.x_IngestionTime ?? "")); + const nowMs = now instanceof Date ? now.getTime() : Date.parse(String(now)); + if (!Number.isFinite(observedAt) || !Number.isFinite(nowMs)) { + return { + ...semantic, + capability: "disabled", + state: "invalid", + reasonCode: "invalid-ingestion-time", + evidenceLabel: "Observation time is missing or invalid", + ageHours: null, + }; + } + + const ageHours = Math.max(0, (nowMs - observedAt) / 3600000); + if (semantic.capability === "disabled") { + return { ...semantic, state: "invalid", ageHours }; + } + if ( + (classContract?.evidenceClass !== "inventory" && (currentValue === null || currentValue < 0 || limit === null)) || + (semantic.classId === "premium-ssd-v2" && (currentValue === null || currentValue < 0)) + ) { + return { + ...semantic, + capability: "disabled", + state: "invalid", + reasonCode: classContract?.evidenceClass === "inventory" ? "invalid-inventory-value" : "invalid-provider-values", + evidenceLabel: classContract?.evidenceClass === "inventory" + ? "Premium SSD v2 size is missing or invalid" + : "Provider values are missing or invalid", + ageHours, + }; + } + if (ageHours > freshnessHours) { + return { + ...semantic, + capability: "disabled", + state: "stale", + reasonCode: "stale-observation", + evidenceLabel: `Stale observation — older than ${freshnessHours} hours`, + ageHours, + }; + } + if (semantic.capability === "descriptive-only") { + return { ...semantic, state: "unclassified", ageHours }; + } + + if (classContract.evidenceClass === "inventory") { + return { + ...semantic, + state: "inventory", + ageHours, + currentValue, + limit: null, + utilizationPercent: null, + headroom: null, + }; + } + + if (limit === 0) { + return { + ...semantic, + state: currentValue > 0 ? "invalid" : "no-entitlement", + reasonCode: currentValue > 0 ? "conflicting-provider-values" : "no-entitlement", + evidenceLabel: currentValue > 0 + ? "Conflicting provider values" + : "No quota reported or no entitlement", + ageHours, + currentValue, + limit, + utilizationPercent: null, + headroom: null, + }; + } + if (limit < 0) { + return { + ...semantic, + capability: "disabled", + state: "invalid", + reasonCode: "unexpected-negative-limit", + evidenceLabel: "Negative provider limit — interpretation unverified", + ageHours, + }; + } + + const utilizationPercent = 100 * currentValue / limit; + const state = + utilizationPercent >= 100 ? "exhausted" : + utilizationPercent >= 90 ? "action" : + utilizationPercent >= 80 ? "watch" : + "healthy"; + return { + ...semantic, + state, + ageHours, + currentValue, + limit, + utilizationPercent, + headroom: limit - currentValue, + }; +} + +export function resolveCapacityHistoryCapability(snapshotCount, options = {}) { + const count = Number(snapshotCount); + const inventory = options.evidenceClass === "inventory"; + if (!Number.isInteger(count) || count < 1) { + return { mode: "unavailable", reasonCode: "no-compatible-snapshots", confidence: null }; + } + if (count === 1) { + return { mode: "current-only", reasonCode: "collecting-history", confidence: null }; + } + if (inventory) { + return { mode: "observed-history", reasonCode: "inventory-runway-disabled", confidence: null }; + } + if (count === 2) { + return { mode: "observed-delta", reasonCode: "insufficient-trend-points", confidence: null }; + } + if (count < 7) { + return { mode: "provisional-runway", reasonCode: "low-confidence", confidence: "low" }; + } + return { mode: "trend-runway", reasonCode: "compatible-daily-history", confidence: "normal" }; +} + +function capacityClass(value) { + const classId = normalizeCapacityClassId(value); + if (classId === "home") throw new Error("A source class is required for this query."); + return CAPACITY_CLASS_REGISTRY[classId]; +} + +function kqlString(value, fieldName) { + const string = String(value ?? "").trim(); + if (!string || string.length > 512 || /[\u0000-\u001f\u007f]/.test(string)) { + throw new Error(`${fieldName} must be 1-512 printable characters.`); + } + return JSON.stringify(string); +} + +function validateCapacityFilters(filters = {}) { + if (!filters || typeof filters !== "object" || Array.isArray(filters)) { + throw new Error("Capacity filters must be an object."); + } + const normalized = {}; + for (const column of ["SubAccountId", "location"]) { + const values = filters[column]; + if (values === undefined) continue; + if (!Array.isArray(values) || values.length > 8) { + throw new Error(`Capacity filter '${column}' must contain at most 8 values.`); + } + const clean = [...new Set(values.map((value) => String(value).trim()))]; + if (clean.some((value) => !value || value.length > 256)) { + throw new Error(`Capacity filter '${column}' values must be 1-256 characters.`); + } + if (clean.length) normalized[column] = clean; + } + const unsupported = Object.keys(filters).filter((key) => !["SubAccountId", "location"].includes(key)); + if (unsupported.length) throw new Error(`Unsupported capacity filter '${unsupported[0]}'.`); + return normalized; +} + +function buildCapacityWhere(filters, target = "quota") { + return Object.entries(validateCapacityFilters(filters)) + .map(([column, values]) => { + const targetColumn = target === "cost" && column === "location" ? "RegionId" : column; + return `| where ${targetColumn} in~ (${values.map((value) => kqlString(value, column)).join(", ")})`; + }) + .join("\n"); +} + +function demandPredicate(predicateId) { + switch (predicateId) { + case "app-service-cost": + return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage' +| where x_ResourceType in~ ('microsoft.web/hostingenvironments','microsoft.web/serverfarms','microsoft.web/sites','microsoft.web/sites/slots') +| where ConsumedUnit in~ ('Hours','GiB Hours','GB','Units/Hour')`; + case "azure-ai-cost": + return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage' +| where x_ResourceType in~ ('microsoft.cognitiveservices/accounts','microsoft.cognitiveservices/accounts/projects') +| where ConsumedUnit in~ ('Units','Seconds','Minutes','Hours')`; + case "compute-cost": + return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage' +| where x_ResourceType in~ ('microsoft.compute/virtualmachines','microsoft.compute/virtualmachinescalesets','microsoft.compute/virtualmachinescalesets/virtualmachines') +| where ConsumedUnit in~ ('Hours','Units/Hour','GB','Units/Month','Units','GB/Month')`; + case "azure-sql-cost": + return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage' +| where x_ResourceType startswith 'microsoft.sql/' +| where ConsumedUnit in~ ('Units/Day','Hours','GB/Month','Units/Hour','Units/Month')`; + case "storage-cost": + return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage' +| where x_ResourceType =~ 'microsoft.storage/storageaccounts' +| where ConsumedUnit in~ ('Units','Units/Hour','GB','GB/Month','Units/Month')`; + case "capacity-reservation-cost": + return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage' +| where isnotempty(CapacityReservationId) +| where CapacityReservationStatus in~ ('Used','Unused') +| where ConsumedUnit =~ 'Hours'`; + case "premium-ssd-cost": + return `| where ProviderName =~ 'Microsoft' and ChargeCategory =~ 'Usage' +| where x_ResourceType =~ 'microsoft.compute/disks'`; + default: + throw new Error(`Unsupported capacity demand predicate '${predicateId}'.`); + } +} + +function exactWhere(selection, fields) { + if (!selection || typeof selection !== "object" || Array.isArray(selection)) { + throw new Error("A structured capacity selection is required."); + } + return fields.map(([selectionName, column]) => { + const value = selection[selectionName]; + return `| where ${column} =~ ${kqlString(value, selectionName)}`; + }).join("\n"); +} + +export function buildCapacityHomeQuery() { + return `Quota() +| where x_SourceType in~ (${CAPACITY_SOURCE_TYPES.map((value) => JSON.stringify(value)).join(", ")}) +| summarize + Observations=count(), + Resources=dcount(ResourceId), + DistinctDays=dcount(startofday(x_IngestionTime)), + LatestObservation=max(x_IngestionTime) + by x_SourceType +| order by x_SourceType asc +| take 7`; +} + +export function buildCapacitySchemaQuery(source) { + if (source === "Quota") { + return `Quota() | getschema | project ColumnName, ColumnType | order by ColumnName asc | take 200`; + } + if (source === "Costs") { + return `Costs() | getschema | project ColumnName, ColumnType | order by ColumnName asc | take 500`; + } + throw new Error(`Unsupported schema source '${source}'.`); +} + +export function buildCapacityCoverageQuery(classId, filters = {}) { + const contract = capacityClass(classId); + const where = buildCapacityWhere(filters); + return `Quota() +| where x_SourceType =~ ${JSON.stringify(contract.sourceType)} +${where} +| summarize + Observations=count(), + Resources=dcount(ResourceId), + DistinctDays=dcount(startofday(x_IngestionTime)), + FirstObservation=min(x_IngestionTime), + LastObservation=max(x_IngestionTime), + Units=make_set(unit, 64)`; +} + +export function buildCapacityDemandCoverageQuery(classId, filters = {}) { + const contract = capacityClass(classId); + const costWhere = buildCapacityWhere(filters, "cost"); + return `Costs() +| where ChargePeriodStart >= startofday(now()-430d) +${demandPredicate(contract.demandPredicateId)} +${costWhere} +| summarize + Observations=count(), + DistinctDays=dcount(startofday(ChargePeriodStart)), + FirstObservation=min(ChargePeriodStart), + LastObservation=max(ChargePeriodStart), + Units=make_set(ConsumedUnit, 64)`; +} + +export function buildCapacityCurrentQuery(classId, filters = {}) { + const contract = capacityClass(classId); + const where = buildCapacityWhere(filters); + return `Quota() +| where x_SourceType =~ ${JSON.stringify(contract.sourceType)} +${where} +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project ProviderName, ResourceId, ResourceName, ResourceType, SubAccountId, displayName, location, currentValue, limit, unit, x_SourceType, x_SourceVersion, x_IngestionTime +| order by SubAccountId asc, location asc, ResourceName asc +| take ${CAPACITY_LIMITS.primaryRows + 1}`; +} + +export function buildCapacitySelectorQuery(classId, filters = {}) { + const contract = capacityClass(classId); + const where = buildCapacityWhere(filters); + return `Quota() +| where x_SourceType =~ ${JSON.stringify(contract.sourceType)} +${where} +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project SubAccountId, location, ResourceId, ResourceName, displayName, unit, x_SourceType, x_SourceVersion, x_IngestionTime +| order by SubAccountId asc, location asc, ResourceName asc +| take ${CAPACITY_LIMITS.selectorKeys + 1}`; +} + +export function buildCapacityHistoryQuery(classId, selection) { + const contract = capacityClass(classId); + const identity = contract.evidenceClass === "inventory" + ? exactWhere(selection, [["resourceId", "ResourceId"]]) + : exactWhere(selection, [ + ["subAccountId", "SubAccountId"], + ["location", "location"], + ["resourceName", "ResourceName"], + ["unit", "unit"], + ["sourceVersion", "x_SourceVersion"], + ]); + return `Quota() +| where x_SourceType =~ ${JSON.stringify(contract.sourceType)} +${identity} +| extend Day=startofday(x_IngestionTime) +| summarize arg_max(x_IngestionTime, *) by Day, ResourceId +| project Day, ResourceId, ResourceName, displayName, SubAccountId, location, currentValue, limit, unit, x_SourceType, x_SourceVersion, x_IngestionTime +| order by Day asc +| take ${CAPACITY_LIMITS.dailyPoints + 1}`; +} + +export function buildCapacityHeatmapQuery(classId, selection, filters = {}) { + const contract = capacityClass(classId); + const where = buildCapacityWhere(filters); + if (contract.evidenceClass === "inventory") { + return `Quota() +| where x_SourceType =~ ${JSON.stringify(contract.sourceType)} +${where} +| summarize arg_max(x_IngestionTime, *) by ResourceId +| summarize + ObservedObjects=count(), + ObservedGiB=sum(currentValue), + x_IngestionTime=max(x_IngestionTime) + by SubAccountId, location, x_SourceType, x_SourceVersion +| order by SubAccountId asc, location asc +| take ${CAPACITY_LIMITS.heatmapCells + 1}`; + } + const metric = exactWhere(selection, [ + ["resourceName", "ResourceName"], + ["unit", "unit"], + ["sourceVersion", "x_SourceVersion"], + ]); + return `Quota() +| where x_SourceType =~ ${JSON.stringify(contract.sourceType)} +${metric} +${where} +| summarize arg_max(x_IngestionTime, *) by ResourceId +| project SubAccountId, location, ResourceId, ResourceName, currentValue, limit, unit, x_SourceType, x_SourceVersion, x_IngestionTime +| order by SubAccountId asc, location asc +| take ${CAPACITY_LIMITS.heatmapCells + 1}`; +} + +export function buildCapacityDemandSelectorQuery(classId, filters = {}) { + const contract = capacityClass(classId); + const costWhere = buildCapacityWhere(filters, "cost"); + if (contract.id === "premium-ssd-v2") { + return `let disks = Quota() +| where x_SourceType =~ 'PremiumSSDv2Disk' +${buildCapacityWhere(filters)} +| summarize arg_max(x_IngestionTime, *) by ResourceId +| extend JoinResourceId=tolower(ResourceId) +| project JoinResourceId, InventoryResourceId=ResourceId, DiskName=ResourceName, SubAccountId, location, SizeGiB=currentValue, x_IngestionTime; +let diskCost = Costs() +| where ChargePeriodStart >= startofday(now()-430d) +${demandPredicate(contract.demandPredicateId)} +${costWhere} +| extend JoinResourceId=tolower(ResourceId) +| summarize FirstDay=min(startofday(ChargePeriodStart)), LastDay=max(startofday(ChargePeriodStart)), EffectiveCost=sum(EffectiveCost), Rows=count() + by JoinResourceId, ResourceId, x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, BillingCurrency; +disks +| join kind=leftouter diskCost on JoinResourceId +| project InventoryResourceId, DiskName, SubAccountId, location, SizeGiB, ResourceId, x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, BillingCurrency, FirstDay, LastDay, EffectiveCost, Rows, x_IngestionTime +| order by InventoryResourceId asc, BillingCurrency asc +| take ${CAPACITY_LIMITS.selectorKeys + 1}`; + } + + const extraDimensions = contract.id === "capacity-reservations" + ? ", CapacityReservationId, CapacityReservationStatus" + : ""; + return `Costs() +| where ChargePeriodStart >= startofday(now()-430d) +${demandPredicate(contract.demandPredicateId)} +${costWhere} +| summarize + FirstDay=min(startofday(ChargePeriodStart)), + LastDay=max(startofday(ChargePeriodStart)), + BilledQuantity=sum(ConsumedQuantity), + EffectiveCost=sum(EffectiveCost), + Rows=count() + by x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, ConsumedUnit, BillingCurrency${extraDimensions} +| order by ConsumedUnit asc, x_SkuMeterSubcategory asc, SkuMeter asc +| take ${CAPACITY_LIMITS.selectorKeys + 1}`; +} + +export function buildCapacityDemandHistoryQuery(classId, selection, filters = {}) { + const contract = capacityClass(classId); + const costWhere = buildCapacityWhere(filters, "cost"); + const commonSelection = exactWhere(selection, [ + ["meterCategory", "x_SkuMeterCategory"], + ["meterSubcategory", "x_SkuMeterSubcategory"], + ["meter", "SkuMeter"], + ["priceId", "SkuPriceId"], + ["currency", "BillingCurrency"], + ]); + if (contract.id === "premium-ssd-v2") { + const disk = exactWhere(selection, [["resourceId", "ResourceId"]]); + return `Costs() +| where ChargePeriodStart >= startofday(now()-430d) +${demandPredicate(contract.demandPredicateId)} +${costWhere} +${disk} +${commonSelection} +| summarize EffectiveCost=sum(EffectiveCost), Rows=count() + by Day=startofday(ChargePeriodStart), ResourceId, x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, BillingCurrency +| order by Day asc +| take ${CAPACITY_LIMITS.dailyPoints + 1}`; + } + const quantitySelection = exactWhere(selection, [["unit", "ConsumedUnit"]]); + const reservationSelection = contract.id === "capacity-reservations" + ? exactWhere(selection, [ + ["capacityReservationId", "CapacityReservationId"], + ["capacityReservationStatus", "CapacityReservationStatus"], + ]) + : ""; + return `Costs() +| where ChargePeriodStart >= startofday(now()-430d) +${demandPredicate(contract.demandPredicateId)} +${costWhere} +${commonSelection} +${quantitySelection} +${reservationSelection} +| summarize BilledQuantity=sum(ConsumedQuantity), EffectiveCost=sum(EffectiveCost), Rows=count() + by Day=startofday(ChargePeriodStart), x_SkuMeterCategory, x_SkuMeterSubcategory, SkuMeter, SkuPriceId, ConsumedUnit, BillingCurrency +| order by Day asc +| take ${CAPACITY_LIMITS.dailyPoints + 1}`; +} + +export function buildCapacityReservationReconciliationQuery(filters = {}) { + const quotaWhere = buildCapacityWhere(filters); + const costWhere = buildCapacityWhere(filters, "cost"); + return `let inventory = Quota() +| where x_SourceType =~ 'CapacityReservation' +${quotaWhere} +| summarize arg_max(x_IngestionTime, *) by ResourceId +| extend GroupKey=tolower(ResourceId) +| project GroupKey, GroupResourceId=ResourceId, GroupName=ResourceName, SubAccountId, location, x_SourceVersion, x_IngestionTime; +let billed = Costs() +| where ChargePeriodStart >= startofday(now()-430d) +${demandPredicate("capacity-reservation-cost")} +${costWhere} +| extend CapacityReservationGroupId=extract(@"(?i)^(.*)/capacityreservations/[^/]+$", 1, CapacityReservationId) +| where isnotempty(CapacityReservationGroupId) +| extend GroupKey=tolower(CapacityReservationGroupId) +| summarize + UsedHours=sumif(ConsumedQuantity, CapacityReservationStatus =~ 'Used'), + UnusedHours=sumif(ConsumedQuantity, CapacityReservationStatus =~ 'Unused'), + ReservationCount=dcount(CapacityReservationId), + LinkedResources=dcount(ResourceId), + FirstDay=min(startofday(ChargePeriodStart)), + LastDay=max(startofday(ChargePeriodStart)) + by GroupKey, CostGroupResourceId=CapacityReservationGroupId, BillingCurrency; +inventory +| join kind=fullouter billed on GroupKey +| extend ReconciliationState=case( + isnotempty(GroupResourceId) and isnotempty(CostGroupResourceId), 'matched', + isnotempty(GroupResourceId), 'inventory-only', + 'cost-only') +| project GroupResourceId=coalesce(GroupResourceId, CostGroupResourceId), GroupName, SubAccountId, location, BillingCurrency, UsedHours, UnusedHours, ReservationCount, LinkedResources, FirstDay, LastDay, x_SourceVersion, x_IngestionTime, ReconciliationState +| order by GroupResourceId asc, BillingCurrency asc +| take ${CAPACITY_LIMITS.primaryRows + 1}`; +} + +function boundedCollection(rows, limit, overflowMode = "truncate") { + const overflow = rows.length > limit; + if (overflow && overflowMode === "disable") { + return { status: "disabled", rows: [], limit, totalReturned: rows.length, truncated: false, reasonCode: "refine-filters" }; + } + return { + status: overflow ? "bounded" : "ready", + rows: rows.slice(0, limit), + limit, + totalReturned: rows.length, + truncated: overflow, + reasonCode: overflow ? "result-cap-reached" : null, + }; +} + +function annotateCapacityRows(rows, now) { + return rows.map((row) => ({ ...row, semantic: classifyCapacityObservation(row, now) })); +} + +const QUOTA_REQUIRED_FIELDS = Object.freeze([ + "ResourceId", + "ResourceName", + "SubAccountId", + "location", + "currentValue", + "limit", + "unit", + "x_SourceType", + "x_SourceVersion", + "x_IngestionTime", +]); + +const COST_REQUIRED_FIELDS = Object.freeze([ + "ChargePeriodStart", + "ProviderName", + "ChargeCategory", + "ResourceId", + "SubAccountId", + "RegionId", + "x_ResourceType", + "x_SkuMeterCategory", + "x_SkuMeterSubcategory", + "SkuMeter", + "SkuPriceId", + "EffectiveCost", + "BillingCurrency", +]); + +function requiredCostFields(classId) { + if (classId === "premium-ssd-v2") return COST_REQUIRED_FIELDS; + const fields = [...COST_REQUIRED_FIELDS, "ConsumedQuantity", "ConsumedUnit"]; + if (classId === "capacity-reservations") { + fields.push("CapacityReservationId", "CapacityReservationStatus"); + } + return fields; +} + +function assessSchema(rows, requiredFields, source) { + const availableNames = new Set(rows.map((row) => normalizeCapacityValue(row.ColumnName))); + const missingFields = requiredFields.filter((field) => !availableNames.has(field.toLowerCase())); + return { + source, + available: missingFields.length === 0, + fields: rows.map((row) => ({ name: row.ColumnName, type: row.ColumnType })), + requiredFields, + missingFields, + reasonCode: missingFields.length ? "required-source-field-unavailable" : null, + }; +} + +function selectorMatches(row, selection, mapping) { + return mapping.every(([selectionName, rowName]) => + normalizeCapacityValue(row[rowName]) === normalizeCapacityValue(selection?.[selectionName]) + ); +} + +function validateQuotaSelection(contract, selectors, selection, mode) { + if (!selection) return; + const mapping = contract.evidenceClass === "inventory" + ? [["resourceId", "ResourceId"]] + : mode === "metric" + ? [["resourceName", "ResourceName"], ["unit", "unit"], ["sourceVersion", "x_SourceVersion"]] + : [ + ["subAccountId", "SubAccountId"], + ["location", "location"], + ["resourceName", "ResourceName"], + ["unit", "unit"], + ["sourceVersion", "x_SourceVersion"], + ]; + if (!selectors.some((row) => selectorMatches(row, selection, mapping))) { + throw new Error("The selected quota key is not present in the bounded selector catalog."); + } +} + +function validateDemandSelection(classId, selectors, selection) { + if (!selection) return; + const common = [ + ["meterCategory", "x_SkuMeterCategory"], + ["meterSubcategory", "x_SkuMeterSubcategory"], + ["meter", "SkuMeter"], + ["priceId", "SkuPriceId"], + ["currency", "BillingCurrency"], + ]; + const mapping = classId === "premium-ssd-v2" + ? [["resourceId", "InventoryResourceId"], ...common] + : classId === "capacity-reservations" + ? [ + ...common, + ["unit", "ConsumedUnit"], + ["capacityReservationId", "CapacityReservationId"], + ["capacityReservationStatus", "CapacityReservationStatus"], + ] + : [...common, ["unit", "ConsumedUnit"]]; + if (!selectors.some((row) => selectorMatches(row, selection, mapping))) { + throw new Error("The selected demand key is not present in the bounded selector catalog."); + } +} + +export async function getCapacity(clusterUri, database, classId = "home", options = {}) { + const normalizedClassId = normalizeCapacityClassId(classId); + const generatedAt = new Date(); + const quotaSchemaRows = await runQuery(clusterUri, database, buildCapacitySchemaQuery("Quota")); + const quotaSchema = assessSchema(quotaSchemaRows, QUOTA_REQUIRED_FIELDS, "Quota"); + + if (normalizedClassId === "home") { + const rows = quotaSchema.available + ? await runQuery(clusterUri, database, buildCapacityHomeQuery()) + : []; + const bySource = new Map(rows.map((row) => [normalizeCapacityValue(row.x_SourceType), row])); + return { + classId: "home", + classes: Object.values(CAPACITY_CLASS_REGISTRY).map((contract) => ({ + ...contract, + capability: quotaSchema.available + ? { mode: "descriptive-only", reasonCode: "class-evidence-index", evidenceLabel: contract.evidenceLabel } + : { mode: "disabled", reasonCode: quotaSchema.reasonCode, evidenceLabel: "Quota source fields are unavailable" }, + summary: bySource.get(contract.sourceType.toLowerCase()) ?? { + x_SourceType: contract.sourceType, + Observations: 0, + Resources: 0, + DistinctDays: 0, + LatestObservation: null, + }, + })), + schema: { quota: quotaSchema }, + generatedAt: generatedAt.toISOString(), + }; + } + + const contract = capacityClass(normalizedClassId); + const filters = options.filters ?? {}; + const costSchemaRows = await runQuery(clusterUri, database, buildCapacitySchemaQuery("Costs")); + const costSchema = assessSchema(costSchemaRows, requiredCostFields(normalizedClassId), "Costs"); + const baseQueries = {}; + if (quotaSchema.available) { + baseQueries.current = buildCapacityCurrentQuery(normalizedClassId, filters); + baseQueries.selectors = buildCapacitySelectorQuery(normalizedClassId, filters); + baseQueries.coverage = buildCapacityCoverageQuery(normalizedClassId, filters); + } + if (costSchema.available && (normalizedClassId !== "premium-ssd-v2" || quotaSchema.available)) { + baseQueries.demandSelectors = buildCapacityDemandSelectorQuery(normalizedClassId, filters); + baseQueries.demandCoverage = buildCapacityDemandCoverageQuery(normalizedClassId, filters); + } + + const baseEntries = Object.entries(baseQueries); + const baseResults = await Promise.all(baseEntries.map(([, query]) => runQuery(clusterUri, database, query))); + const data = Object.fromEntries(baseEntries.map(([key], index) => [key, baseResults[index]])); + data.current ??= []; + data.selectors ??= []; + data.demandSelectors ??= []; + + validateQuotaSelection(contract, data.selectors, options.quotaSelection, "series"); + validateQuotaSelection(contract, data.selectors, options.metricSelection, "metric"); + validateDemandSelection(normalizedClassId, data.demandSelectors, options.demandSelection); + + const selectedQueries = {}; + if (quotaSchema.available && options.quotaSelection) { + selectedQueries.history = buildCapacityHistoryQuery(normalizedClassId, options.quotaSelection); + } + if (quotaSchema.available && (contract.evidenceClass === "inventory" || options.metricSelection)) { + selectedQueries.heatmap = buildCapacityHeatmapQuery(normalizedClassId, options.metricSelection, filters); + } + if (costSchema.available && options.demandSelection) { + selectedQueries.demandHistory = buildCapacityDemandHistoryQuery(normalizedClassId, options.demandSelection, filters); + } + if (quotaSchema.available && costSchema.available && normalizedClassId === "capacity-reservations") { + selectedQueries.reconciliation = buildCapacityReservationReconciliationQuery(filters); + } + + const selectedEntries = Object.entries(selectedQueries); + const selectedResults = await Promise.all(selectedEntries.map(([, query]) => runQuery(clusterUri, database, query))); + Object.assign(data, Object.fromEntries(selectedEntries.map(([key], index) => [key, selectedResults[index]]))); + + const currentRows = annotateCapacityRows(data.current, generatedAt); + const heatmapRows = data.heatmap + ? (contract.evidenceClass === "inventory" ? data.heatmap : annotateCapacityRows(data.heatmap, generatedAt)) + : []; + const distinctHistoryDays = new Set((data.history ?? []).map((row) => String(row.Day))).size; + const historyBounds = data.history + ? boundedCollection(data.history, CAPACITY_LIMITS.dailyPoints, "disable") + : null; + const table = boundedCollection(currentRows, CAPACITY_LIMITS.primaryRows); + const selectorBounds = boundedCollection(data.selectors, CAPACITY_LIMITS.selectorKeys); + const demandSelectorBounds = boundedCollection(data.demandSelectors, CAPACITY_LIMITS.selectorKeys); + const seriesBounds = data.demandHistory + ? boundedCollection(data.demandHistory, CAPACITY_LIMITS.dailyPoints, "disable") + : null; + const heatmapBounds = data.heatmap + ? boundedCollection(heatmapRows, CAPACITY_LIMITS.heatmapCells, "disable") + : null; + if (heatmapBounds?.status === "disabled") heatmapBounds.status = "heatmap-disabled"; + const coverageRow = data.coverage?.[0] ?? {}; + const demandCoverageRow = data.demandCoverage?.[0] ?? {}; + const enabledRows = currentRows.filter((row) => row.semantic.capability === "enabled").length; + const classCapability = !quotaSchema.available + ? { mode: "disabled", reasonCode: quotaSchema.reasonCode, evidenceLabel: "Quota source fields are unavailable" } + : enabledRows > 0 && normalizedClassId === "compute" + ? { mode: "enabled", reasonCode: "registered-metrics-present", evidenceLabel: contract.evidenceLabel } + : { mode: "descriptive-only", reasonCode: "fail-closed-class-view", evidenceLabel: contract.evidenceLabel }; + const firstHistory = data.history?.[0]?.Day ?? null; + const lastHistory = data.history?.at(-1)?.Day ?? null; + const meterKey = options.demandSelection + ? [ + options.demandSelection.meterCategory, + options.demandSelection.meterSubcategory, + options.demandSelection.meter, + options.demandSelection.priceId, + options.demandSelection.unit, + options.demandSelection.currency, + ].map((value) => String(value ?? "").trim()).join("|") + : null; + + return { + classId: normalizedClassId, + contract, + capability: classCapability, + coverage: { + state: Number(coverageRow.Observations ?? 0) > 0 ? "observed" : "no-evidence", + reasonCode: Number(coverageRow.Observations ?? 0) > 0 ? null : "collection-outcome-unknown", + observations: Number(coverageRow.Observations ?? 0), + resources: Number(coverageRow.Resources ?? 0), + distinctDays: Number(coverageRow.DistinctDays ?? 0), + firstObservation: coverageRow.FirstObservation ?? null, + lastObservation: coverageRow.LastObservation ?? null, + units: coverageRow.Units ?? [], + }, + schema: { quota: quotaSchema, costs: costSchema }, + table: { ...table, rowLimit: CAPACITY_LIMITS.primaryRows }, + current: table, + selectors: { ...selectorBounds, items: selectorBounds.rows, itemLimit: CAPACITY_LIMITS.selectorKeys }, + history: historyBounds + ? { + ...historyBounds, + points: historyBounds.rows, + pointLimit: CAPACITY_LIMITS.dailyPoints, + ...resolveCapacityHistoryCapability(distinctHistoryDays, contract), + distinctDays: distinctHistoryDays, + firstDate: firstHistory, + lastDate: lastHistory, + } + : { + status: quotaSchema.available ? "no-selection" : "disabled", + mode: "unavailable", + reasonCode: quotaSchema.available ? "select-exact-series" : quotaSchema.reasonCode, + rows: [], + points: [], + pointLimit: CAPACITY_LIMITS.dailyPoints, + distinctDays: 0, + firstDate: null, + lastDate: null, + }, + heatmap: heatmapBounds ?? { + status: quotaSchema.available ? "no-selection" : "heatmap-disabled", + rows: [], + limit: CAPACITY_LIMITS.heatmapCells, + reasonCode: quotaSchema.available ? "select-exact-metric" : quotaSchema.reasonCode, + }, + series: seriesBounds + ? { + ...seriesBounds, + points: seriesBounds.rows, + pointLimit: CAPACITY_LIMITS.dailyPoints, + unit: options.demandSelection?.unit ?? null, + meterKey, + } + : { + status: costSchema.available ? "no-selection" : "disabled", + rows: [], + points: [], + pointLimit: CAPACITY_LIMITS.dailyPoints, + unit: null, + meterKey: null, + reasonCode: costSchema.available ? "select-exact-series" : costSchema.reasonCode, + }, + demand: { + contract: CAPACITY_DEMAND_REGISTRY[normalizedClassId], + capability: costSchema.available + ? { mode: "parallel", reasonCode: "billed-demand-evidence", evidenceLabel: CAPACITY_DEMAND_REGISTRY[normalizedClassId].label } + : { mode: "disabled", reasonCode: costSchema.reasonCode, evidenceLabel: "Required cost source fields are unavailable" }, + coverage: { + state: Number(demandCoverageRow.Observations ?? 0) > 0 ? "observed" : "no-evidence", + reasonCode: Number(demandCoverageRow.Observations ?? 0) > 0 ? null : "no-billed-demand-evidence", + observations: Number(demandCoverageRow.Observations ?? 0), + distinctDays: Number(demandCoverageRow.DistinctDays ?? 0), + firstObservation: demandCoverageRow.FirstObservation ?? null, + lastObservation: demandCoverageRow.LastObservation ?? null, + units: demandCoverageRow.Units ?? [], + }, + selectors: { ...demandSelectorBounds, items: demandSelectorBounds.rows, itemLimit: CAPACITY_LIMITS.selectorKeys }, + history: seriesBounds ?? { status: "no-selection", rows: [], reasonCode: "select-exact-series" }, + }, + reconciliation: data.reconciliation + ? boundedCollection(data.reconciliation, CAPACITY_LIMITS.primaryRows) + : null, + generatedAt: generatedAt.toISOString(), + }; +} + + + +/** + * Run all dashboard queries in parallel for the resolved window and shape the + * result into a single payload the renderer consumes. + */ +export async function getDashboard(clusterUri, database, preset = "all", filters = {}) { + const win = await resolveWindow(clusterUri, database, preset); + if (win.empty) { + return { window: win, empty: true, generatedAt: new Date().toISOString() }; + } + + const filterWhere = buildFilterWhere(filters); + const period = `| where ChargePeriodStart >= datetime(${win.start}) and ChargePeriodStart < datetime(${win.end})${filterWhere}`; + + const queries = { + // KPI totals — Understand Usage & Cost + Quantify Business Value + summary: `Costs() ${period} | summarize Billed=sum(BilledCost), Effective=sum(EffectiveCost), List=sum(ListCost), Contracted=sum(ContractedCost), Resources=dcount(ResourceId), Services=dcount(ServiceName), Subscriptions=dcount(SubAccountId), Regions=dcount(RegionId), Rows=count()`, + // Allocation KPI — percentage-untagged-costs + tagged: `Costs() ${period} | extend _t=iff(isnull(Tags) or array_length(bag_keys(Tags))==0,'Untagged','Tagged') | summarize Cost=sum(EffectiveCost) by _t`, + // Rate Optimization — commitment coverage (Committed vs Standard pricing) + pricing: `Costs() ${period} | summarize Cost=sum(EffectiveCost) by PricingCategory`, + // Reporting & Analytics — monthly-cost-trend (Billed vs Effective) + trend: `Costs() ${period} | summarize Billed=sum(BilledCost), Effective=sum(EffectiveCost) by Month=format_datetime(startofmonth(ChargePeriodStart),'yyyy-MM') | order by Month asc`, + // Understand Usage & Cost — cost by service category + serviceCategory: `Costs() ${period} | summarize Cost=sum(EffectiveCost) by ServiceCategory | where Cost > 0 | order by Cost desc`, + // top-services-by-cost + topServices: `Costs() ${period} | summarize Cost=sum(EffectiveCost) by ServiceName | top 10 by Cost desc`, + // top-resource-groups-by-cost + topResourceGroups: `Costs() ${period} | where isnotempty(x_ResourceGroupName) | summarize Cost=sum(EffectiveCost) by x_ResourceGroupName | top 10 by Cost desc`, + // cost-by-region-trend (top regions by cost) + topRegions: `Costs() ${period} | where isnotempty(RegionId) | summarize Cost=sum(EffectiveCost) by RegionId | top 12 by Cost desc`, + // Charge category mix (Usage / Purchase / Adjustment) + chargeCategory: `Costs() ${period} | summarize Cost=sum(EffectiveCost) by ChargeCategory | where Cost != 0 | order by Cost desc`, + // macc-consumption-vs-commitment — MACC burn rate (graceful: returns CommitmentAmount=0 if no MACC data) + macc: `let con = toscalar(Costs() ${period} | where not(ChargeCategory == 'Purchase' and isnotempty(CommitmentDiscountCategory)) | summarize sum(EffectiveCost)); +let com = toscalar(Transactions() | where isnotnull(x_MonetaryCommitment) | summarize sum(x_MonetaryCommitment)); +let com0 = coalesce(todouble(com), 0.0); +print ConsumptionAmount=con, CommitmentAmount=com0, CommitmentBurnPercent=iff(com0 > 0, con / com0 * 100.0, 0.0)`, + }; + + const entries = Object.entries(queries); + const results = await Promise.all( + entries.map(([, csl]) => runQuery(clusterUri, database, csl)) + ); + const data = Object.fromEntries(entries.map(([key], i) => [key, results[i]])); + + return { + window: win, + empty: false, + data, + generatedAt: new Date().toISOString(), + }; +} + +// --- tokenomics (AI / Azure OpenAI token economics) --------------------------- +// +// Grounded in the FinOps toolkit AI query catalog (ai-token-usage-breakdown, +// ai-model-cost-comparison, ai-daily-trend) and the FinOps Foundation +// "Token Consumption Metrics" KPI (Cost per Token = Total Cost / Tokens Used). +// +// Token meters are scoped to Azure OpenAI subcategories whose SKU description +// is denominated in tokens, which excludes non-token AI meters (image/media, +// Cognitive Search). ConsumedQuantity is the token count per the catalog. +const AI_SCOPE = `| where x_SkuMeterSubcategory has 'OpenAI' and x_SkuDescription contains 'Token'`; + +// Direction: descriptions use abbreviations (Inp / Outp / cached Inp), so the +// canonical contains "Input"/"Output" test is replaced with term/substring +// matching that also splits cached input out as its own (cheaper) bucket. +const DIRECTION = `extend Direction = case( + x_SkuDescription has 'Outp' or x_SkuDescription contains 'Output', 'Output', + x_SkuDescription contains 'cached', 'Cached input', + x_SkuDescription has 'Inp' or x_SkuDescription contains 'Input', 'Input', + 'Other')`; + +// Collapse verbose SKU descriptions to a clean model family, e.g. +// "Azure OpenAI - gpt 4.1 cached Inp glbl Tokens - US East 2" -> "GPT 4.1". +const MODEL_FAMILY = `extend Model = x_SkuDescription +| extend Model = replace_regex(Model, @'^Azure OpenAI(?: GPT5)?\\s*-\\s*', '') +| extend Model = replace_regex(Model, @'(?i)[\\s-]+(cached[\\s-]+)?(inp|inpt|outp|out|chat|media)([\\s-].*)?$', '') +| extend Model = replace_regex(trim(@'[\\s-]+', Model), @'(?i)^gpt', 'GPT')`; + +export async function getTokenomics(clusterUri, database, preset = "all", filters = {}) { + const win = await resolveWindow(clusterUri, database, preset); + if (win.empty) { + return { window: win, empty: true, generatedAt: new Date().toISOString() }; + } + const filterWhere = buildFilterWhere(filters); + const period = `| where ChargePeriodStart >= datetime(${win.start}) and ChargePeriodStart < datetime(${win.end})${filterWhere}`; + + const queries = { + // Token KPI totals — Token Consumption Metrics. Models is collapsed + // through the same MODEL_FAMILY normalization as the "models" query + // below, so "Models in use" counts distinct model families, not raw + // (and often duplicated) billing-SKU description strings. + summary: `Costs() ${period} ${AI_SCOPE} | ${MODEL_FAMILY} | summarize Tokens=sum(ConsumedQuantity), Effective=sum(EffectiveCost), List=sum(ListCost), Models=dcount(Model), Resources=dcount(ResourceId), Rows=count()`, + // Total cloud effective cost in the window — for AI share-of-spend + totalCloud: `Costs() ${period} | summarize Effective=sum(EffectiveCost)`, + // ai-token-usage-breakdown — direction mix (input/cached/output) + direction: `Costs() ${period} ${AI_SCOPE} | ${DIRECTION} | summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Direction`, + // ai-model-cost-comparison — by model family with cost per 1K tokens + models: `Costs() ${period} ${AI_SCOPE} | ${MODEL_FAMILY} | summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost), List=sum(ListCost) by Model | extend CostPer1K=iff(Tokens==0, 0.0, Cost/Tokens*1000) | top 12 by Cost desc`, + // ai-daily-trend (monthly variant) — token volume + AI cost over time + trend: `Costs() ${period} ${AI_SCOPE} | summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Month=format_datetime(startofmonth(ChargePeriodStart),'yyyy-MM') | order by Month asc`, + // ai-cost-by-application — AI cost showback by app/team/env/cost-center + byApplication: `Costs() ${period} ${AI_SCOPE} +| extend Application = tostring(Tags['application']), Team = tostring(Tags['team']) +| extend CostCenter = coalesce(tostring(Tags['cost-center']), tostring(Tags['CostCenter']), '') +| extend Environment = tostring(Tags['environment']) +| summarize TokenCount=sum(ConsumedQuantity), EffectiveCost=sum(EffectiveCost) + by Application, Team, CostCenter, Environment +| extend CostPer1KTokens = iff(TokenCount == 0, 0.0, EffectiveCost / TokenCount * 1000) +| top 12 by EffectiveCost desc`, + }; + + const entries = Object.entries(queries); + const results = await Promise.all(entries.map(([, csl]) => runQuery(clusterUri, database, csl))); + const data = Object.fromEntries(entries.map(([key], i) => [key, results[i]])); + + const tokenRows = data.summary?.[0]?.Rows ?? 0; + return { + window: win, + empty: tokenRows === 0, + data, + generatedAt: new Date().toISOString(), + }; +} + +// --- shared page runner ------------------------------------------------------- +// Resolves the window, builds a named map of KQL queries from the period clause, +// runs them in parallel, and returns { window, empty, data, generatedAt }. +async function runPage(clusterUri, database, preset, buildQueries, filters = {}) { + const win = await resolveWindow(clusterUri, database, preset); + if (win.empty) return { window: win, empty: true, generatedAt: new Date().toISOString() }; + const filterWhere = buildFilterWhere(filters); + const period = `| where ChargePeriodStart >= datetime(${win.start}) and ChargePeriodStart < datetime(${win.end})${filterWhere}`; + const queries = buildQueries(period, win); + const entries = Object.entries(queries); + const results = await Promise.all(entries.map(([, csl]) => runQuery(clusterUri, database, csl))); + const data = Object.fromEntries(entries.map(([key], i) => [key, results[i]])); + return { window: win, empty: false, data, generatedAt: new Date().toISOString() }; +} + +// --- Allocation page ---------------------------------------------------------- +// FinOps "Allocation" capability. Grounded in catalog queries: +// percentage-untagged-costs, percentage-unallocated-costs, tagging-policy-compliance, +// allocation-accuracy-index, cost-by-financial-hierarchy. Tag policy keys are tuned +// to this estate's taxonomy (CostCenter/env/org); allocation evidence also honours +// the enriched x_CostCenter / x_CostAllocationRuleName columns. +const NON_PURCHASE = `| where not(ChargeCategory == 'Purchase' and isnotempty(CommitmentDiscountCategory))`; + +export async function getAllocation(clusterUri, database, preset = "all", filters = {}) { + return runPage(clusterUri, database, preset, (period) => ({ + // Single-pass core: total, untagged, attributed (AAI), compliant + core: `let req=dynamic(['CostCenter','env','org']); +let ev=dynamic(['cost-center','team','owner','application','product','CostCenter','org','env','Project']); +Costs() ${period} ${NON_PURCHASE} +| extend tk=coalesce(bag_keys(Tags), dynamic([])) +| extend isUntagged = array_length(tk)==0 +| extend hasEvidence = isnotempty(x_CostAllocationRuleName) or isnotempty(x_CostCenter) or array_length(set_intersect(tk,ev))>0 +| extend isCompliant = array_length(set_intersect(tk,req))==array_length(req) +| summarize Total=sum(EffectiveCost), Untagged=sumif(EffectiveCost,isUntagged), Attributed=sumif(EffectiveCost,hasEvidence), Compliant=sumif(EffectiveCost,isCompliant), Subs=dcount(SubAccountId)`, + // cost-by-financial-hierarchy (tuned to org/Project/env taxonomy) + hierarchy: `Costs() ${period} +| extend Org=tostring(Tags['org']), Project=tostring(Tags['Project']), Env=tostring(Tags['env']) +| summarize Cost=sum(EffectiveCost) by Org, Project, Env +| where Cost > 0 | top 12 by Cost desc`, + // Tag-key coverage — cost touched by each tag key. Excludes Azure/FTK + // auto-injected tags (ftk-*, cm-*, costanalysis-parent, aks-managed-*) + // so governance-relevant keys aren't crowded out by system noise. + tagKeys: `Costs() ${period} +| mv-expand k=bag_keys(Tags) to typeof(string) +| where isnotempty(k) and k !in ('ftk-tool','ftk-version','cm-resource-parent','costanalysis-parent') and not(k startswith 'aks-managed-') +| summarize Cost=sum(EffectiveCost) by k +| top 12 by Cost desc`, + // Cost by subscription (SubAccountName) + bySubscription: `Costs() ${period} | where isnotempty(SubAccountName) | summarize Cost=sum(EffectiveCost) by SubAccountName | top 10 by Cost desc`, + }), filters); +} + +// --- Rate optimization page --------------------------------------------------- +// FinOps "Rate Optimization" capability. Grounded in catalog queries: +// savings-summary-report, commitment-discount-waste, compute-spend-commitment-coverage, +// commitment-discount-utilization. (cost-optimization-index/COIN is omitted because it +// depends on Recommendations(), which is empty in this estate, so it would always read 100.) +// Commitment utilization is derived as the effective-cost complement of waste, the cleanest +// single-basis definition for a grand-total KPI. +export async function getRate(clusterUri, database, preset = "all", filters = {}) { + return runPage(clusterUri, database, preset, (period) => ({ + // savings-summary-report — ESR + negotiated/commitment/total savings + savings: `Costs() ${period} ${NON_PURCHASE} +| extend neg=iff(ListCost<ContractedCost,real(0),ListCost-ContractedCost) +| extend com=iff(ContractedCost<EffectiveCost,real(0),ContractedCost-EffectiveCost) +| extend tot=iff(ListCost<EffectiveCost,real(0),ListCost-EffectiveCost) +| summarize List=sum(ListCost), Effective=sum(EffectiveCost), Negotiated=sum(neg), Commitment=sum(com), Total=sum(tot)`, + // commitment-discount-waste (grand total, effective-cost basis) + commitment: `Costs() ${period} | where isnotempty(CommitmentDiscountId) ${NON_PURCHASE} +| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost)`, + // compute-spend-commitment-coverage + computeCoverage: `Costs() ${period} ${NON_PURCHASE} | where ServiceCategory=='Compute' +| summarize Committed=sumif(EffectiveCost,isnotempty(CommitmentDiscountCategory)), Contracted=sum(ContractedCost)`, + // commitment-discount-utilization — consumed core-hours by commitment type + coreHours: `Costs() ${period} +| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores, 0)) +| extend ch=iff(cores>0, cores*ConsumedQuantity, toreal('')) +| extend t=iff(isempty(CommitmentDiscountType),'On Demand',CommitmentDiscountType) +| summarize CoreHours=sum(ch) by t | where CoreHours > 0 | order by CoreHours desc`, + // Per-commitment waste — which reservations/plans are underutilized + byCommitment: `Costs() ${period} | where isnotempty(CommitmentDiscountName) ${NON_PURCHASE} +| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost) by CommitmentDiscountName +| where Unused > 0 | top 10 by Unused desc`, + // commitment-utilization-score (formal KPI) — per-commitment and grand-total utilization score + commitmentUtilScore: `let rows = materialize(Costs() ${period} | where isnotempty(CommitmentDiscountId) +| extend Potential = case(ChargeCategory == 'Purchase', toreal(0), isnotempty(CommitmentDiscountCategory), toreal(EffectiveCost), toreal(0)) +| extend Amount = iff(CommitmentDiscountStatus == 'Used', Potential, toreal(0))); +let byCommit = rows | summarize Amount=sum(Amount), Potential=sum(Potential) by CommitmentDiscountName, CommitmentDiscountCategory, CommitmentDiscountType +| extend Score = iff(Potential > 0, Amount / Potential * 100.0, 0.0); +union byCommit, (byCommit | summarize Amount=sum(Amount), Potential=sum(Potential) +| extend CommitmentDiscountName='(Grand Total)', CommitmentDiscountCategory='', CommitmentDiscountType='', Score=iff(Potential>0, Amount/Potential*100.0, 0.0)) +| project CommitmentDiscountName, CommitmentDiscountCategory, CommitmentDiscountType, Amount, Potential, Score | order by Potential desc`, + // top-commitment-transactions — largest RI/SP purchases + topCommitmentTxns: `Costs() ${period} | where ChargeCategory != 'Usage' and isnotempty(CommitmentDiscountType) and BilledCost > 0 +| summarize BilledCost=sum(BilledCost), EffectiveCost=sum(EffectiveCost) + by CommitmentDiscountName, CommitmentDiscountType, CommitmentDiscountCategory +| top 10 by BilledCost desc`, + }), filters); +} + +// --- Usage & unit economics page ---------------------------------------------- +// FinOps "Usage Optimization" + "Unit Economics". Grounded in catalog queries: +// compute-cost-per-core, cost-per-gb-stored, storage-tier-distribution, top-resource-types-by-cost. +export async function getUsage(clusterUri, database, preset = "all", filters = {}) { + return runPage(clusterUri, database, preset, (period) => ({ + // compute-cost-per-core + compute: `Costs() ${period} ${NON_PURCHASE} +| extend vm = x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') and ChargeCategory=='Usage' +| extend isComputeCommit = x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') +| extend cores = iff(vm, toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores)), toint('')) +| extend ch = iff(vm and isnotempty(cores), toreal(cores*ConsumedQuantity), toreal('')) +| summarize ComputeEff=sumif(EffectiveCost,vm), UnusedCommit=sumif(EffectiveCost, CommitmentDiscountStatus=='Unused' and isnotempty(CommitmentDiscountCategory) and isComputeCommit), CoreHours=sum(ch)`, + // cost-per-gb-stored + storage: `Costs() ${period} | where ServiceCategory=='Storage' and ChargeCategory=='Usage' +| extend gb = case(ConsumedUnit endswith 'PB', toreal(ConsumedQuantity)*1048576.0, ConsumedUnit endswith 'TB', toreal(ConsumedQuantity)*1024.0, ConsumedUnit endswith 'MB', toreal(ConsumedQuantity)/1024.0, toreal(ConsumedQuantity)) +| summarize Cost=sum(EffectiveCost), GBMonths=sum(gb)`, + // storage-tier-distribution + storageTiers: `Costs() ${period} | where ServiceCategory=='Storage' and ChargeCategory=='Usage' +| extend Tier = case( + x_SkuTier in ('Hot','Standard','Premium'), 'Frequent', + x_SkuTier in ('Cool','Cold','Archive'), 'Infrequent', + x_SkuMeterSubcategory has_any ('Hot','Standard','Premium','Frequent'), 'Frequent', + x_SkuMeterSubcategory has_any ('Cool','Cold','Archive'), 'Infrequent', + 'Unclassified') +| summarize Cost=sum(EffectiveCost) by Tier | where Cost > 0 | order by Cost desc`, + // top-resource-types-by-cost — Resources is a distinct-resource count + // (dcount(ResourceId)), matching the same "Resources" label semantics + // used by the Overview summary KPI, not a row count. + topResourceTypes: `Costs() ${period} | where isnotempty(ResourceType) | summarize Resources=dcount(ResourceId), Cost=sum(EffectiveCost) by ResourceType | top 10 by Cost desc`, + // compute-cost-per-core grouped by VM series — where the expensive cores are + perCoreSeries: `Costs() ${period} | where x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') and ChargeCategory=='Usage' +| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores)) +| extend ch=iff(isnotempty(cores), toreal(cores*ConsumedQuantity), toreal('')) +| summarize Eff=sum(EffectiveCost), CH=sum(ch) by x_SkuMeterSubcategory +| where CH > 100 | extend PerCore=Eff/CH | top 10 by Eff desc`, + // grand total for share-of-cost on the resource-type table + total: `Costs() ${period} | summarize Total=sum(EffectiveCost)`, + }), filters); +} + +// --- Anomalies & forecast page ------------------------------------------------ +// FinOps "Anomaly Management" + "Forecasting" + data-freshness (Data Ingestion). +// Grounded in catalog queries: cost-anomaly-detection, anomaly-detection-rate, +// anomaly-variance-total, monthly-cost-change-percentage, cost-forecasting-model, +// data-update-frequency, cost-visibility-delay. Time-series array outputs are +// flattened with mv-expand so the renderer can chart them. +export async function getAnomaly(clusterUri, database, preset = "all", filters = {}) { + return runPage(clusterUri, database, preset, (period, win) => { + // forecast uses full history for accuracy; horizon = 4 months past the last data month + const dmax = new Date(win.dataMax); + const monthStart = new Date(Date.UTC(dmax.getUTCFullYear(), dmax.getUTCMonth(), 1)); + const horizon = new Date(Date.UTC(dmax.getUTCFullYear(), dmax.getUTCMonth() + 4, 1)); + const isoH = horizon.toISOString().slice(0, 10); + const fcDays = Math.round((horizon - monthStart) / 86400000); + return { + // cost-anomaly-detection + anomaly-variance-total (flattened daily series) + daily: `let s=datetime(${win.start}); let e=datetime(${win.end}); +Costs() | where ChargePeriodStart>=s and ChargePeriodStart<e ${NON_PURCHASE} +| summarize DC=sum(EffectiveCost) by bin(ChargePeriodStart,1d) +| make-series Cost=sum(DC) default=0.0 on ChargePeriodStart from s to e step 1d +| extend (flag,score,baseline)=series_decompose_anomalies(Cost,1.5) +| mv-expand Day=ChargePeriodStart to typeof(datetime), Cost to typeof(real), flag to typeof(real), baseline to typeof(real) +| project Day, Cost=toreal(Cost), Flag=toint(flag), Baseline=toreal(baseline)`, + // monthly-cost-change-percentage + monthlyChange: `Costs() ${period} | summarize Eff=sum(EffectiveCost) by M=startofmonth(ChargePeriodStart) +| order by M asc | extend PrevEff=prev(Eff) +| project Month=format_datetime(M,'yyyy-MM'), EffChangePct=iff(isempty(PrevEff),0.0,(Eff-PrevEff)*100.0/PrevEff), Eff`, + // cost-forecasting-model (monthly, forecasts past the last data month) + forecast: `let s=datetime(${win.dataMin}); Costs() | where ChargePeriodStart>=s +| summarize Eff=sum(EffectiveCost) by bin(ChargePeriodStart,1d) +| make-series Actual=sum(Eff) default=0.0 on ChargePeriodStart from s to datetime(${isoH}) step 1d +| extend Fc=series_decompose_forecast(Actual,${fcDays}) +| mv-expand Day=ChargePeriodStart to typeof(datetime), Actual to typeof(real), Fc to typeof(real) +| extend M=startofmonth(Day) +| summarize Actual=sum(toreal(Actual)), Forecast=sum(toreal(Fc)) by M +| order by M asc | project Month=format_datetime(M,'yyyy-MM'), Actual, Forecast`, + // data-update-frequency + cost-visibility-delay + freshness: `Costs() ${period} | where isnotnull(x_IngestionTime) +| summarize LastUpdate=max(x_IngestionTime), Rows=count(), P50=percentile(todouble((x_IngestionTime-ChargePeriodEnd)/1h),50), P90=percentile(todouble((x_IngestionTime-ChargePeriodEnd)/1h),90)`, + }; + }, filters); +} + +// --- AI & emerging workloads page --------------------------------------------- +// The 2026 FinOps "AI as a Technology Scope" view: the whole AI/ML estate, not +// just tokens. Tokenomics (above) drills into Azure OpenAI token unit economics; +// this page covers foundation models, cognitive services, the ML platform, AI +// Search / retrieval, and GPU-accelerated compute together. +// +// GPU detection is a case-insensitive regex over the concatenated service and +// SKU text, so N-series capacity billed through Virtual Machines or Virtual +// Machine Scale Sets is attributed to the AI estate even though its +// ServiceCategory is Compute. The regex replaces the equivalent toupper() +// form to keep case handling out of comparison position. +const AI_GPU = `strcat(' ', ServiceName, ' ', x_SkuMeterCategory, ' ', x_SkuMeterSubcategory, ' ', x_SkuInstanceType, ' ', tostring(SkuMeter), ' ') matches regex @'(?i)(^|[^a-z0-9])(nc|nd|nv|ng)[a-z0-9_\\-]*'`; + +// The AI/ML estate: the declared FOCUS service category, plus two services that +// sit outside it but are unambiguously AI workloads, plus GPU compute. +const AI_ESTATE = `| where ServiceCategory == 'AI and Machine Learning' or ServiceName in ('Azure AI Search', 'Azure Databricks') or (${AI_GPU})`; + +// Token meters on this page are scoped by meter category rather than the +// tokenomics page's SKU-description test, so Foundry-billed models beyond +// Azure OpenAI (Deepseek, Phi, and later additions) are counted too. +const AI_TOKENS = `x_SkuMeterCategory == 'Foundry Models' and PricingUnit == 'Units'`; + +// Capability taxonomy, ordered most specific first: GPU wins over service name +// because N-series capacity bills through generic Compute services. +const AI_CAPABILITY = `extend Capability = case( + _gpu, 'GPU / accelerated compute', + x_SkuMeterCategory == 'Foundry Models' or x_SkuMeterSubcategory has_any ('OpenAI', 'Deepseek', 'GPT'), 'Foundation models (LLM)', + ServiceName == 'Azure AI Search', 'AI Search / retrieval', + ServiceName == 'Azure Machine Learning', 'ML platform & compute', + ServiceName == 'Azure Databricks', 'ML / analytics platform', + ServiceName == 'Azure AI Video Indexer' or x_SkuMeterSubcategory has_any ('Vision', 'Speech', 'Translator', 'Content Understanding', 'Video Indexer', 'Bing', 'Content Safety', 'Phi'), 'Cognitive services', + ServiceName == 'Azure AI Bot Service', 'Bot & agents', + 'Other AI/ML')`; + +// Tag keys that evidence an owning application or team. Allocation coverage is +// reported per dimension rather than as one blended score, so a gap in tagging +// stays distinguishable from a gap in cost-center enrichment. +const AI_APP_TAGS = `dynamic(['application', 'app', 'workload', 'product', 'service'])`; +const AI_OWNER_TAGS = `dynamic(['owner', 'team', 'createdby'])`; + +/** + * First day of the most recent *complete* month in the data. + * + * Ingestion usually stops mid-month, so the newest month in the window is + * partial. Comparing it against a full prior month reports a collapse that is + * an artifact of the ingestion cut-off, not a change in spend, so + * month-over-month evidence is anchored to the last closed month instead. + */ +export function lastClosedMonthStart(dataMax) { + if (!dataMax) return null; + const max = new Date(dataMax); + if (Number.isNaN(max.getTime())) return null; + const monthStart = startOfMonthUTC(max); + const lastDayOfMonth = new Date(addMonthsUTC(monthStart, 1).getTime() - 86400000); + return isoDay(max) === isoDay(lastDayOfMonth) ? monthStart : addMonthsUTC(monthStart, -1); +} + +export async function getAi(clusterUri, database, preset = "all", filters = {}) { + let closedMonth = null; + let built = null; + const page = await runPage(clusterUri, database, preset, (period, win) => { + closedMonth = lastClosedMonthStart(win.dataMax); + // Fall back to the window start when the data is too short to contain a + // closed month, so the driver comparison stays inside the window. + const closedDay = isoDay(closedMonth ?? new Date(win.start)); + built = { + // Single monthly rollup that powers both trend charts and every + // month-over-month KPI, so the page costs one scan instead of the + // five scalar round-trips the source dashboard used. + monthly: `Costs() ${period} +| extend _gpu = ${AI_GPU} +| extend _ai = ServiceCategory == 'AI and Machine Learning' or ServiceName in ('Azure AI Search', 'Azure Databricks') or _gpu +| extend _tok = ${AI_TOKENS} +| summarize Cloud=sum(EffectiveCost), Estate=sumif(EffectiveCost, _ai), MlGpu=sumif(EffectiveCost, ServiceName == 'Azure Machine Learning' or _gpu), + Tokens=sumif(ConsumedQuantity, _tok), TokenCost=sumif(EffectiveCost, _tok) + by Month=format_datetime(startofmonth(ChargePeriodStart), 'yyyy-MM') +| order by Month asc`, + // AI/ML estate by capability over time — stacked column source. + capabilityTrend: `Costs() ${period} ${AI_ESTATE} +| extend _gpu = ${AI_GPU} +| ${AI_CAPABILITY} +| summarize Cost=sum(EffectiveCost) by Month=format_datetime(startofmonth(ChargePeriodStart), 'yyyy-MM'), Capability +| order by Month asc`, + // Estate composition: cost, distinct services, and share per capability. + capability: `Costs() ${period} ${AI_ESTATE} +| extend _gpu = ${AI_GPU} +| ${AI_CAPABILITY} +| summarize Cost=sum(EffectiveCost), Services=dcount(ServiceName) by Capability +| where Cost > 0 +| order by Cost desc`, + // Estate spend by billing service. + byService: `Costs() ${period} ${AI_ESTATE} +| summarize Cost=sum(EffectiveCost), Meters=dcount(x_SkuMeterSubcategory) by Service=ServiceName, Category=ServiceCategory +| where Cost > 0 +| top 15 by Cost desc`, + // AI Search / retrieval meters. + search: `Costs() ${period} +| where ServiceName == 'Azure AI Search' +| summarize Cost=sum(EffectiveCost), Quantity=sum(ConsumedQuantity) by Meter=tostring(SkuMeter), Unit=PricingUnit +| where Cost > 0 +| top 10 by Cost desc`, + // ML platform and GPU compute components. + mlGpu: `Costs() ${period} +| extend _gpu = ${AI_GPU} +| where ServiceName == 'Azure Machine Learning' or _gpu +| summarize Cost=sum(EffectiveCost), Quantity=sum(PricingQuantity) by Component=x_SkuMeterSubcategory, Unit=PricingUnit +| where Cost > 0 +| top 12 by Cost desc`, + // ML compute unit economics — $/VM-hour and $/1K core-hours by series. + mlUnit: `Costs() ${period} +| where ServiceName == 'Azure Machine Learning' and x_SkuMeterCategory == 'Virtual Machines' +| extend CoreHours = todouble(coalesce(x_SkuCoreCount, 0)) * PricingQuantity +| summarize Cost=sum(EffectiveCost), VmHours=sum(PricingQuantity), CoreHours=sum(CoreHours) by Series=x_SkuMeterSubcategory +| where VmHours > 0 +| extend PerVmHour=Cost/VmHours, Per1KCoreHours=iff(CoreHours > 0, Cost/CoreHours*1000.0, real(null)) +| top 10 by Cost desc`, + // Foundation model benchmark — cost per 1M tokens by model family. + // 'embed' is a genuine substring test: the token appears fused inside + // meter names such as 'text-embedding-3-large'. + modelBench: `Costs() ${period} +| where ${AI_TOKENS} +| extend Family = iff(SkuMeter contains 'embed', 'Embeddings', x_SkuMeterSubcategory) +| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Family +| where Tokens > 0 +| extend Cpmt=Cost/Tokens*1000000.0 +| order by Cost desc`, + // Token direction mix by meter name. Meter names fuse the direction + // into abbreviations ("Inpt", "Outp", "cchd", "cd inp"), so the tests + // anchor to a word boundary rather than a bare substring: a plain + // `contains 'out'` would also classify a future "Throughput" meter as + // output. Verified against the live meter catalog with zero rows + // falling through to 'Other'. + direction: `Costs() ${period} +| where ${AI_TOKENS} +| extend Direction = case( + SkuMeter contains 'embed', 'Embedding', + SkuMeter matches regex @'(?i)\\bcd\\s+wr', 'Cached write', + SkuMeter has_any ('cchd', 'cached') or SkuMeter matches regex @'(?i)\\bcd\\s+inp', 'Cached input', + SkuMeter matches regex @'(?i)\\b(out|opt)', 'Output', + SkuMeter matches regex @'(?i)\\binp', 'Input', + 'Other') +| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Direction +| where Tokens > 0 +| extend Cpmt=Cost/Tokens*1000000.0 +| order by Tokens desc`, + // Cognitive and specialized AI services, excluding token meters. + cognitive: `Costs() ${period} +| where ServiceName in ('Azure AI Services', 'Azure AI Video Indexer') and x_SkuMeterCategory != 'Foundry Models' +| summarize Cost=sum(EffectiveCost), Units=sum(ConsumedQuantity) by Service=x_SkuMeterSubcategory +| where Cost > 0 +| top 12 by Cost desc`, + // Allocation coverage per dimension, plus the estate total that every + // coverage percentage is measured against. + allocation: `Costs() ${period} ${AI_ESTATE} +| extend tk = coalesce(bag_keys(Tags), dynamic([])) +| summarize Total=sum(EffectiveCost), + App=sumif(EffectiveCost, array_length(set_intersect(tk, ${AI_APP_TAGS})) > 0), + Owner=sumif(EffectiveCost, array_length(set_intersect(tk, ${AI_OWNER_TAGS})) > 0), + CostCenter=sumif(EffectiveCost, isnotempty(x_CostCenter)), + ResourceGroup=sumif(EffectiveCost, isnotempty(x_ResourceGroupName))`, + // Estate spend by owning team or cost center. + // Tag values are free text, so the same owner arrives in several + // casings ("ACM9000" / "acm9000"). Splitting them into separate rows + // understates the real owner and reads as broken data on screen, so + // group case-insensitively and keep the most expensive casing as the + // display label. tolower() is the grouping key here, not a comparison — + // KQL's comparison operators are already case-insensitive. + byOwner: `Costs() ${period} ${AI_ESTATE} +| extend Owner = coalesce(tostring(Tags['owner']), tostring(Tags['team']), x_CostCenter, x_ResourceGroupName, '(unassigned)') +| summarize Cost=sum(EffectiveCost) by Owner +| where Cost > 0 +| summarize Cost=sum(Cost), Variants=dcount(Owner), (TopCost, Owner)=arg_max(Cost, Owner) by OwnerKey=tolower(Owner) +| project Owner, Cost, Variants +| top 12 by Cost desc`, + // Commitment posture across the estate. + posture: `Costs() ${period} ${AI_ESTATE} +| summarize Total=sum(EffectiveCost), Committed=sumif(EffectiveCost, isnotempty(CommitmentDiscountCategory))`, + // AI-scoped rate recommendations and commitment transactions. Both + // render as descriptive counts; an empty result means no AI-scoped + // evidence was ingested, not that no opportunity exists. + recommendations: `Recommendations() +| where ResourceType has_any ('MachineLearning', 'CognitiveServices', 'Search/search', 'Databricks', 'BotService', 'VideoIndexer') + or x_RecommendationDescription has_any ('AI', 'OpenAI', 'GPU', 'machine learning', 'cognitive') +| summarize Count=count()`, + transactions: `Transactions() +| where ChargeDescription has_any ('NC', 'ND', 'NV', 'NG', 'GPU', 'Machine Learning', 'Cognitive', 'OpenAI', 'Databricks', 'AI Search') +| summarize Count=count()`, + // Top movers: the last closed month against the month before it, so a + // partial ingestion month can't read as a collapse in spend. A single + // conditional aggregation replaces the source dashboard's self-join. + drivers: `let _last = datetime(${closedDay}); +let _prev = datetime_add('month', -1, _last); +Costs() ${period} ${AI_ESTATE} +| where ChargePeriodStart >= _prev and ChargePeriodStart < datetime_add('month', 1, _last) +| summarize Cost=sumif(EffectiveCost, ChargePeriodStart >= _last), Prev=sumif(EffectiveCost, ChargePeriodStart < _last) + by Service=ServiceName, Meter=x_SkuMeterSubcategory +| where Cost > 0 or Prev > 0 +| extend Change=Cost-Prev +| top 12 by Cost desc`, + }; + return built; + }, filters); + if (!page.empty) { + page.lastClosedMonth = closedMonth ? isoDay(closedMonth).slice(0, 7) : null; + // Ship the queries that actually ran, so the panel "KQL" dialog shows + // executed text rather than a hand-maintained copy that can drift. + page.kql = built; + } + return page; +} diff --git a/.github/extensions/ftk-local-dashboard/public/app.css b/.github/extensions/ftk-local-dashboard/public/app.css new file mode 100644 index 000000000..34f642ea5 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/public/app.css @@ -0,0 +1,1080 @@ +:root { + --accent: #3b82f6; + --pos: #10b981; + --neg: #ef4444; + --warn: #f59e0b; + --grid: var(--border-color-default, rgba(128, 128, 128, 0.22)); + --muted: var(--text-color-muted, #6b7280); + --card-bg: var(--background-color-default, #ffffff); + --radius: 12px; + --gap: 16px; + --border-muted: var(--border-color-muted, rgba(128, 128, 128, 0.3)); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0 20px 28px; + background: var(--background-color-default, #f6f8fa); + color: var(--text-color-default, #1f2328); + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + font-size: var(--text-body-medium, 14px); + line-height: var(--leading-body-medium, 20px); + -webkit-font-smoothing: antialiased; +} + +.muted { color: var(--muted); } + +/* ---------- header ---------- */ +.app-header { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + padding: 18px 0 14px; + margin-bottom: 6px; + background: linear-gradient(var(--background-color-default, #f6f8fa) 78%, transparent); + flex-wrap: wrap; +} +.title-block h1 { + margin: 0; + font-size: var(--text-title-large, 24px); + font-weight: var(--font-weight-semibold, 600); + letter-spacing: -0.01em; +} +.title-block .sub { + margin: 4px 0 0; + font-size: 12.5px; + color: var(--muted); +} +.title-block .sub code { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11.5px; + padding: 1px 5px; + border-radius: 5px; + background: color-mix(in srgb, var(--muted) 14%, transparent); +} + +.controls { display: flex; align-items: center; gap: 10px; } +.seg { + display: inline-flex; + border: 1px solid var(--grid); + border-radius: 9px; + overflow: hidden; +} +.seg button { + appearance: none; + border: 0; + background: transparent; + color: var(--text-color-default, #1f2328); + padding: 10px 13px; + min-height: 44px; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + border-left: 1px solid var(--grid); +} +.seg button:first-child { border-left: 0; } +.seg button.active { background: var(--accent); color: #fff; } +.seg button:not(.active):hover { background: color-mix(in srgb, var(--accent) 12%, transparent); } +#preset[hidden] { display: none; } + +.btn { + appearance: none; + border: 1px solid var(--grid); + background: var(--card-bg); + color: var(--text-color-default, #1f2328); + padding: 10px 13px; + min-height: 44px; + border-radius: 9px; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; +} +.btn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); } +.btn:active { transform: translateY(1px); } + +/* ---------- tabs ---------- */ +.tabs { + display: flex; + gap: 4px; + margin: 2px 0 18px; + border-bottom: 1px solid var(--grid); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tabs button { + appearance: none; + border: 0; + background: transparent; + color: var(--muted); + font-size: 13.5px; + font-weight: 600; + padding: 12px 14px 14px; + min-height: 44px; + white-space: nowrap; + flex-shrink: 0; + cursor: pointer; + position: relative; + border-radius: 8px 8px 0 0; +} +.tabs button:hover { color: var(--text-color-default, #1f2328); background: color-mix(in srgb, var(--accent) 8%, transparent); } +.tabs button.active { color: var(--text-color-default, #1f2328); } +.tabs button.active::after { + content: ""; + position: absolute; + left: 10px; right: 10px; bottom: -1px; + height: 2.5px; + border-radius: 2px; + background: var(--accent); +} + +/* ---------- KPI cards ---------- */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--gap); + margin-bottom: 22px; +} +.kpi { + background: var(--card-bg); + border: 1px solid var(--grid); + border-radius: var(--radius); + padding: 15px 16px 14px; + position: relative; + overflow: hidden; +} +.kpi .label { + font-size: 11.5px; + color: var(--muted); + font-weight: 600; +} +.kpi .value { + font-size: 26px; + font-weight: 700; + margin-top: 6px; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.kpi .meta { margin-top: 5px; font-size: 12px; color: var(--muted); } +/* .kpi .pos/.neg/.warn (not scoped to .meta) so KPIs that color the value + itself — e.g. Anomaly's "Last month change" — get the same semantics as + the meta-text convention. */ +.kpi .pos { color: var(--pos); font-weight: 600; } +.kpi .neg { color: var(--neg); font-weight: 600; } +.kpi .warn { color: var(--warn); font-weight: 600; } + +/* Tables compute the same threshold classes as KPI cards, so give them the same + meaning. Without these the classes are inert and every table renders its + thresholds as plain body text. */ +.dtable .pos { color: var(--pos); font-weight: 600; } +.dtable .neg { color: var(--neg); font-weight: 600; } +.dtable .warn { color: var(--warn); font-weight: 600; } + +/* ---------- KPI hierarchy (primary vs reference) ---------- */ +.kpi--primary .value { font-size: 28px; } +.kpi--reference { opacity: 0.75; } + +/* ---------- KPI threshold tooltip ---------- */ +.kpi-tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid currentColor; + background: transparent; + color: var(--muted); + cursor: pointer; + font: 600 9px/1 var(--font-sans, -apple-system, sans-serif); + padding: 0; + margin-left: 4px; + vertical-align: middle; + transition: color 0.15s, border-color 0.15s; +} +.kpi-tip:hover, .kpi-tip:focus-visible { + color: var(--accent); + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* ---------- KPI threshold state: value text carries the signal ---------- */ +.threshold-green { border-color: var(--pos); } +.threshold-amber { border-color: var(--warn); } +.threshold-red { border-color: var(--neg); } +.threshold-green .value { color: var(--pos); } +.threshold-amber .value { color: var(--warn); } +.threshold-red .value { color: var(--neg); } + +/* ---------- triage arrival context banner ---------- */ +.triage-callout { + display: flex; + align-items: center; + gap: 10px; + background: color-mix(in srgb, var(--warn) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--warn) 28%, transparent); + border-radius: 8px; + padding: 10px 14px; + font-size: 13px; + font-weight: 500; + color: color-mix(in srgb, var(--warn) 75%, #000); + margin-bottom: 16px; +} +.triage-callout-icon { font-size: 15px; flex-shrink: 0; line-height: 1; } + +/* ---------- triage strip ---------- */ +.triage-strip { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--gap); + margin-bottom: 22px; +} +.triage-tile { + appearance: none; + background: var(--card-bg); + border: 2px solid var(--grid); + border-radius: var(--radius); + padding: 16px 18px; + cursor: pointer; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + transition: opacity 0.15s; + color: var(--text-color-default, #1f2328); +} +.triage-tile:hover { opacity: 0.82; } +.triage-tile:active { transform: translateY(1px); } +.triage-title { + font-size: 11.5px; + font-weight: 600; + color: var(--muted); +} +.triage-count { + font-size: 30px; + font-weight: 700; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.triage-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + background: color-mix(in srgb, var(--muted) 12%, transparent); + color: var(--muted); +} +.triage-tile.threshold-green .triage-badge { + background: color-mix(in srgb, var(--pos) 15%, transparent); + color: var(--pos); +} +.triage-tile.threshold-amber .triage-badge { + background: color-mix(in srgb, var(--warn) 15%, transparent); + color: var(--warn); +} +.triage-tile.threshold-red .triage-badge { + background: color-mix(in srgb, var(--neg) 15%, transparent); + color: var(--neg); +} +/* Teaser state: this tile's data hasn't loaded yet (visit the tab to + populate it), distinct from a real "no anomalies found" result. */ +.triage-tile.is-teaser { + border-style: dashed; + border-color: var(--grid); +} +.triage-tile.is-teaser .triage-count { color: var(--muted); } +.triage-tile.is-teaser .triage-badge { + background: transparent; + border: 1px dashed var(--muted); + color: var(--muted); +} +.triage-cue { + font-size: 12px; + color: var(--muted); + margin-top: 2px; +} +@media (max-width: 640px) { + .triage-strip { grid-template-columns: 1fr; } +} + +/* ---------- sections & panels ---------- */ +.section-title { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 10px; + margin: 26px 0 12px; +} +.section-title h2 { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; +} +.section-title .domain { + font-size: 11px; + color: var(--muted); +} + +/* Neutral scope caveat shown above a tab's sections. Deliberately not + .triage-callout: that carries a warning tone, and a scope statement is + context, not a problem to act on. */ +.scope-note { + margin: 18px 0 0; + padding: 10px 14px; + border: 1px solid var(--border-muted); + border-radius: 8px; + background: color-mix(in srgb, var(--muted) 6%, transparent); + font-size: 12px; + line-height: 1.55; + color: var(--muted); + max-width: 100%; +} +.scope-note strong { color: inherit; font-weight: 600; } + +.panel-grid { + display: grid; + gap: var(--gap); + grid-template-columns: repeat(12, 1fr); + /* Paired panels in a row share one height (grid default: stretch), so a + 2-col layout reads as symmetric rather than a jagged row of mismatched + card heights. Short, fixed-size content (a capped donut — see + .panel-body:has below) centers vertically to use the shared height + instead of leaving dead space pinned to the top. */ +} +.panel { + background: var(--card-bg); + border: 1px solid var(--grid); + border-radius: var(--radius); + padding: 16px; + min-width: 0; + display: flex; + flex-direction: column; +} +.panel-body { flex: 1; display: flex; flex-direction: column; } +/* A donut is a small, fixed-size (200x200) chart — it never grows to fill a + tall sibling's height. When the row stretches this panel to match, center + the donut in the extra space so it reads as balanced padding, not an + empty gap under a chart pinned to the top. */ +.panel-body:has(> .donut-wrap) { justify-content: center; } +/* Same reasoning for a short table: a one-row result in a panel stretched to + match a tall sibling otherwise leaves ~390px of blank card below it, which + reads as a panel that failed to load. Centering only takes effect when there + is free space, so tall tables are unaffected. */ +.panel-body:has(> .table-scroll) { justify-content: center; } +.panel.col-12 { grid-column: span 12; } +.panel.col-9 { grid-column: span 9; } +.panel.col-8 { grid-column: span 8; } +.panel.col-7 { grid-column: span 7; } +.panel.col-6 { grid-column: span 6; } +.panel.col-5 { grid-column: span 5; } +.panel.col-4 { grid-column: span 4; } +.panel.col-3 { grid-column: span 3; } +@media (max-width: 900px) { + .panel.col-9, .panel.col-8, .panel.col-7, + .panel.col-6, .panel.col-5, .panel.col-4, + .panel.col-3 { grid-column: span 12; } +} +.panel h3 { + margin: 0 0 2px; + font-size: 13.5px; + font-weight: 600; +} +.panel .panel-sub { + margin: 0 0 12px; + font-size: 11.5px; + color: var(--muted); +} + +/* ---------- charts ---------- */ +svg { display: block; width: 100%; overflow: visible; } +.axis-label, .tick { fill: var(--muted); font-size: 11px; } +.grid-line { stroke: var(--grid); stroke-width: 1; } + +.legend { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-top: 10px; } +.legend .item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } +.legend .swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; } +.legend .lv { color: var(--muted); font-variant-numeric: tabular-nums; } + +/* ---------- "missing data" convention (untagged / unclassified / blank) ---------- */ +/* Shared muted/dashed treatment so a placeholder never looks like a real + category rendered in a rotating palette color (donut legends, hbar + legends, and inline table swatches all route through this class). */ +.swatch--unknown { + border: 1.5px dashed var(--muted); + background: transparent !important; +} + +.donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } +/* donut()'s viewBox is a fixed 1:1 square (180x180) — without a cap, the + generic `svg { width: 100% }` rule scales it to fill the whole panel + width, producing an oversized ring and (via grid row-stretch) dragging + sibling panels in the same row up to match its inflated height. */ +.donut-wrap svg { width: 200px; height: 200px; max-width: 100%; flex: none; } +.donut-center .big { font-size: 18px; font-weight: 700; letter-spacing: -0.02em; } +.donut-center .small { font-size: 11px; fill: var(--muted); } + +.hbar-row { font-variant-numeric: tabular-nums; } +.hbar-row .name { fill: var(--text-color-default, #1f2328); font-size: 12px; } +.hbar-row .val { fill: var(--muted); font-size: 11.5px; } +.bar:hover, .arc:hover { opacity: 0.82; cursor: default; } +/* Truncated labels: dotted underline hints there's more text on hover + (the <title> tooltip already carries the full value). */ +.name--truncated { text-decoration: underline dotted; text-decoration-color: var(--muted); text-underline-offset: 2px; } +td.truncate-hint, +.truncate-hint { text-decoration: underline dotted; text-decoration-color: var(--muted); text-underline-offset: 2px; cursor: help; } + +/* ---------- data table ---------- */ +.dtable { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; } +.dtable th, .dtable td { padding: 9px 10px; text-align: right; border-bottom: 1px solid var(--grid); white-space: nowrap; } +.dtable th:first-child, .dtable td:first-child { text-align: left; } +.dtable thead th { color: var(--muted); font-weight: 600; font-size: 11px; } +.dtable tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); } +.dtable .model { display: inline-flex; align-items: center; gap: 7px; font-weight: 500; } +/* Swatches are bare <span>s at some call sites, where the default display:inline + would drop width/height entirely and render nothing. Pin the box explicitly so + the chip shows whether or not it sits inside an inline-flex .model wrapper. */ +.dtable .swatch { display: inline-block; vertical-align: middle; width: 9px; height: 9px; border-radius: 3px; flex: none; } +.dtable .barcell { position: relative; } +.dtable .minibar { display: inline-block; height: 7px; border-radius: 3px; vertical-align: middle; margin-left: 6px; } + +/* ---------- states ---------- */ +.error { + padding: 40px; + text-align: center; + color: var(--muted); + border: 1px solid var(--grid); + border-radius: var(--radius); + background: var(--card-bg); + max-width: 640px; + margin: 40px auto; +} +.error h2 { color: var(--neg); margin: 0 0 8px; font-size: 16px; } +.error code { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 12px; + background: color-mix(in srgb, var(--muted) 14%, transparent); + padding: 2px 6px; + border-radius: 5px; +} +.error pre { + text-align: left; + white-space: pre-wrap; + font-size: 12px; + background: color-mix(in srgb, var(--muted) 10%, transparent); + padding: 10px 12px; + border-radius: 8px; + margin-top: 14px; +} +.error-action { + margin: 14px 0; + text-align: left; +} +.error-cmd { + display: flex; + align-items: center; + gap: 8px; + background: color-mix(in srgb, var(--muted) 10%, transparent); + border: 1px solid var(--grid); + border-radius: 8px; + padding: 8px 12px; + margin: 6px 0; +} +.error-cmd code { + flex: 1; + background: transparent; + padding: 0; +} +.error-detail { margin-top: 14px; } + +/* ---------- diagnostic rail ---------- */ +.diagnostic-rail { + display: flex; + align-items: center; + gap: 8px; + height: 24px; + max-height: 24px; + overflow: hidden; + font-size: 11.5px; + color: var(--muted); + padding: 0 2px; + margin-top: 12px; + font-variant-numeric: tabular-nums; +} +.rail-sep { opacity: 0.45; user-select: none; } +.rail-health--ok { color: var(--pos); } +.rail-health--warn { color: var(--warn); } +.rail-health--error { color: var(--neg); } +.rail-time { cursor: default; } + +/* ---------- footer ---------- */ +.app-footer { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-top: 26px; + padding-top: 14px; + border-top: 1px solid var(--grid); + font-size: 11.5px; + color: var(--muted); +} +.app-footer #footer-meta { font-variant-numeric: tabular-nums; } +.app-footer[hidden] { display: none; } + +.spin { animation: spin 0.8s linear infinite; display: inline-block; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---------- accessibility ---------- */ +*:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 3px; +} + +@media (prefers-reduced-motion: reduce) { + .spin { animation: none; } +} + +/* ---------- panel-header (KQL escape hatch) ---------- */ +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} +.panel-header > div { min-width: 0; } +.panel-header h3 { margin-bottom: 2px; } +.kql-btn { + flex: none; + background: transparent; + border: 1px solid var(--grid); + border-radius: 4px; + color: var(--muted); + cursor: pointer; + font: 600 10px/1 ui-monospace, monospace; + padding: 4px 7px; + opacity: 0.85; + transition: opacity 0.15s, background 0.15s, color 0.15s; +} +.kql-btn:hover { + opacity: 1; + background: color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--accent); + border-color: var(--accent); +} + +/* ---------- KQL dialog ---------- */ +#kql-dialog { + border: 1px solid var(--grid); + border-radius: var(--radius); + background: var(--card-bg); + color: inherit; + padding: 0; + width: min(780px, 96vw); +} +#kql-dialog::backdrop { + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(2px); +} +.kql-dialog-inner { + display: flex; + flex-direction: column; + max-height: 90vh; +} +.kql-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px 12px; + border-bottom: 1px solid var(--grid); + gap: 12px; +} +.kql-dialog-header h3 { margin: 0; font-size: 14px; } +#kql-text { + flex: 1; + border: none; + border-bottom: 1px solid var(--grid); + background: transparent; + color: inherit; + font: 12px/1.55 ui-monospace, Menlo, Consolas, monospace; + padding: 14px 18px; + resize: vertical; + min-height: 200px; + white-space: pre; + overflow-wrap: normal; + overflow-x: auto; +} +.kql-dialog-error { + margin: 0; + padding: 6px 18px 0; + font-size: 11.5px; + color: var(--neg); + min-height: 22px; +} +.kql-dialog-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 10px 18px; +} +.btn-primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.btn-primary:hover { + background: color-mix(in srgb, var(--accent) 85%, #000); +} + +/* ---------- Settings dialog ---------- */ +#settings-dialog { + border: 1px solid var(--grid); + border-radius: var(--radius); + background: var(--card-bg); + color: inherit; + padding: 0; + width: min(440px, 96vw); +} +#settings-dialog::backdrop { + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(2px); +} +.settings-body { padding: 4px 18px 6px; display: flex; flex-direction: column; gap: 12px; } +.settings-row { display: flex; flex-direction: column; gap: 4px; font-size: 12px; } +.settings-row input { + border: 1px solid var(--grid); + border-radius: 6px; + background: transparent; + color: inherit; + font: 13px/1.4 ui-monospace, Menlo, Consolas, monospace; + padding: 7px 9px; +} +.settings-row input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.settings-hint { margin: 0; font-size: 11.5px; color: var(--muted); } + +/* ---------- KQL result table ---------- */ +.kql-result { + border-top: 1px solid var(--grid); + overflow: hidden; +} +.kql-result-meta { + margin: 0; + padding: 6px 18px; + font-size: 11.5px; + color: var(--muted); +} +.kql-result-scroll { + overflow-x: auto; + padding: 0 18px 14px; + max-height: 260px; + overflow-y: auto; +} + + +/* ---------- filter bar ---------- */ +.filter-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 20px; + margin: -12px 0 14px; + background: color-mix(in srgb, var(--accent) 7%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); + border-radius: 10px; + flex-wrap: wrap; +} +.filter-label { + font-size: 11.5px; + font-weight: 600; + color: var(--muted); + white-space: nowrap; +} +.filter-chips { + display: flex; + gap: 6px; + flex-wrap: wrap; + flex: 1; +} +.filter-chip { + display: inline-flex; + align-items: center; + gap: 1px; + background: color-mix(in srgb, var(--accent) 14%, var(--card-bg)); + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); + border-radius: 20px; + padding: 2px 2px 2px 10px; + font-size: 12px; + color: var(--text-color-default, #1f2328); + line-height: 1.5; +} +.filter-chip strong { font-weight: 600; } +.chip-label { display: flex; gap: 4px; align-items: baseline; } +.chip-remove { + all: unset; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--muted); + font-size: 13px; + width: 22px; + height: 22px; + border-radius: 50%; + margin-left: 2px; +} +.chip-remove:hover { + background: color-mix(in srgb, var(--muted) 18%, transparent); + color: var(--text-color-default, #1f2328); +} +.filter-reset { + appearance: none; + background: transparent; + border: 1px solid var(--grid); + border-radius: 6px; + cursor: pointer; + color: var(--muted); + font-size: 11.5px; + font-weight: 600; + padding: 3px 10px; + white-space: nowrap; +} +.filter-reset:hover { + border-color: color-mix(in srgb, var(--muted) 70%, transparent); + color: var(--text-color-default, #1f2328); +} + +/* ---------- interactive chart elements ---------- */ +svg .hbar-filterable { cursor: pointer; } +svg .hbar-filterable:hover rect.hbar { filter: brightness(1.12); } +svg .hbar-filterable:hover text.name { fill: var(--accent); } +svg .hbar-dimmed { opacity: 0.25; } +svg .hbar-selected rect.hbar { stroke: var(--text-color-default, #1f2328); stroke-width: 1.5; stroke-opacity: 0.35; } +svg .hbar-selected text.name { font-weight: 700; fill: var(--accent); } + +@media (prefers-reduced-motion: no-preference) { + svg .hbar-dimmed { transition: opacity 0.12s ease-out; } +} + +/* ---------- filter bar show/hide transition ---------- */ +.filter-bar { + transition: opacity 0.15s ease-out, transform 0.15s ease-out; +} +.filter-bar[hidden] { + display: none !important; +} + +/* ---------- loading skeleton ---------- */ +@keyframes shimmer { + 0% { background-position: -600px 0; } + 100% { background-position: 600px 0; } +} +.skeleton-card { + background: linear-gradient( + 90deg, + var(--grid) 25%, + color-mix(in srgb, var(--grid) 40%, transparent) 50%, + var(--grid) 75% + ); + background-size: 1200px 100%; + animation: shimmer 1.4s linear infinite; + border-radius: var(--radius); + border: 1px solid var(--grid); +} +.skeleton-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--gap); + margin-bottom: var(--gap); +} +.skeleton-kpi-grid .skeleton-card { height: 88px; } +.skeleton-panel-lg { height: 220px; margin-bottom: var(--gap); } +.skeleton-panel-sm { height: 160px; margin-bottom: var(--gap); } +@media (prefers-reduced-motion: reduce) { + .skeleton-card { animation: none; } +} + +/* ---------- experimental tool tabs (query editor) ---------- */ +.tool-panel { display: flex; flex-direction: column; gap: 12px; } +.tool-banner { + background: color-mix(in srgb, var(--warn) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--warn) 35%, transparent); + border-radius: var(--radius); + padding: 10px 14px; + font-size: 12.5px; + line-height: 1.5; + color: var(--text-color-default, inherit); +} +.tool-banner code { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; } +.tool-toolbar { display: flex; align-items: center; gap: 12px; } +.tool-status { font-size: 12px; color: var(--muted); } +/* Fixed height (not viewport-relative) so the editor doesn't crowd out the + result table below it -- this dashboard's other panels are all bounded, + normal-flow blocks (see .kql-result-scroll, .skeleton-panel-lg), and the + whole page scrolls rather than any one panel filling the viewport. */ +.monaco-host { + height: 360px; + border: 1px solid var(--grid); + border-radius: var(--radius); + overflow: hidden; +} +.monaco-fallback { + width: 100%; + height: 360px; + box-sizing: border-box; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 12.5px; + border: 1px solid var(--grid); + border-radius: var(--radius); + padding: 10px 12px; + background: var(--card-bg); + color: inherit; + resize: vertical; +} + +/* ---------- capacity workspace ---------- */ +.capacity-tabs { + display: flex; + gap: 6px; + margin: 0 0 16px; + padding: 2px 0 8px; + overflow-x: auto; + scrollbar-width: thin; +} +.capacity-tabs button, +.capacity-link { + appearance: none; + min-height: 44px; + border: 1px solid var(--grid); + border-radius: 9px; + background: var(--card-bg); + color: var(--text-color-default, #1f2328); + padding: 9px 12px; + font: inherit; + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; +} +.capacity-tabs button[aria-selected="true"] { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--card-bg)); + color: var(--accent); +} +.capacity-tabs button:hover, +.capacity-link:hover { + border-color: color-mix(in srgb, var(--accent) 65%, var(--grid)); + background: color-mix(in srgb, var(--accent) 8%, var(--card-bg)); +} +.capacity-header, +.capacity-home-intro { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin: 0 0 18px; +} +.capacity-header h2, +.capacity-home-intro h2 { margin: 0 0 5px; font-size: 20px; } +.capacity-header p, +.capacity-home-intro p { margin: 0; max-width: 76ch; color: var(--muted); } +.capacity-badge { + flex: none; + border: 1px solid var(--grid); + border-radius: 999px; + padding: 5px 10px; + color: var(--muted); + font: 600 11px/1.3 var(--font-mono, ui-monospace, monospace); + white-space: nowrap; +} +.capacity-definition { + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--grid)); + border-radius: 8px; + background: color-mix(in srgb, var(--accent) 6%, transparent); + color: var(--text-color-default, #1f2328); +} +.capacity-definition p { margin: 0; } +.capacity-selectors { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 12px; + margin: 0 0 18px; +} +.capacity-selector { + display: grid; + gap: 6px; + min-width: 0; + color: var(--muted); + font-size: 12px; + font-weight: 600; +} +.capacity-selector select { + width: 100%; + min-height: 44px; + border: 1px solid var(--grid); + border-radius: 8px; + background: var(--card-bg); + color: var(--text-color-default, #1f2328); + padding: 8px 10px; + font: inherit; +} +.capacity-notice { + border: 1px solid var(--grid); + border-radius: 8px; + padding: 10px 12px; + margin: 0 0 12px; + color: var(--muted); + background: color-mix(in srgb, var(--muted) 5%, transparent); +} +.capacity-notice strong { color: var(--text-color-default, #1f2328); } +.capacity-notice--warning { + border-color: color-mix(in srgb, var(--warn) 55%, var(--grid)); + background: color-mix(in srgb, var(--warn) 9%, transparent); +} +.capacity-notice--error { + border-color: color-mix(in srgb, var(--neg) 55%, var(--grid)); + background: color-mix(in srgb, var(--neg) 8%, transparent); +} +.capacity-table-scroll, +.table-scroll { + width: 100%; + overflow-x: auto; + overscroll-behavior-inline: contain; + /* Overlay scrollbars leave a scrollable table with no visible cue, so a + clipped column reads as corrupted data ("$13,3") rather than as more + content to the right. The right-hand shadow is painted with + background-attachment: scroll while the masking gradient uses local, so it + appears only while there is further to scroll and clears at the end. */ + background: + linear-gradient(to right, var(--card-bg) 30%, transparent) left center, + linear-gradient(to left, var(--card-bg) 30%, transparent) right center, + radial-gradient(farthest-side at 0 50%, color-mix(in srgb, var(--muted) 38%, transparent), transparent) left center, + radial-gradient(farthest-side at 100% 50%, color-mix(in srgb, var(--muted) 38%, transparent), transparent) right center; + background-repeat: no-repeat; + background-size: 34px 100%, 34px 100%, 12px 100%, 12px 100%; + background-attachment: local, local, scroll, scroll; + /* Keep a real scrollbar rather than relying on the overlay one. */ + scrollbar-width: thin; + scrollbar-color: var(--border-muted) transparent; + padding-bottom: 2px; +} +.capacity-table-scroll::-webkit-scrollbar, +.table-scroll::-webkit-scrollbar { height: 8px; } +.capacity-table-scroll::-webkit-scrollbar-track, +.table-scroll::-webkit-scrollbar-track { background: transparent; } +.capacity-table-scroll::-webkit-scrollbar-thumb, +.table-scroll::-webkit-scrollbar-thumb { + background: var(--border-muted); + border-radius: 4px; +} +.capacity-table-scroll::-webkit-scrollbar-thumb:hover, +.table-scroll::-webkit-scrollbar-thumb:hover { background: var(--muted); } +.capacity-table-scroll .dtable th:first-child, +.capacity-table-scroll .dtable td:first-child, +.table-scroll .dtable th:first-child, +.table-scroll .dtable td:first-child, +.capacity-heatmap th:first-child { + position: sticky; + left: 0; + background: var(--card-bg); + z-index: 1; +} +.capacity-state { + display: inline-flex; + align-items: center; + min-height: 24px; + border: 1px solid var(--grid); + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; +} +.capacity-state--healthy { border-color: var(--pos); } +.capacity-state--watch, +.capacity-state--action { border-color: var(--warn); } +.capacity-state--exhausted, +.capacity-state--invalid, +.capacity-state--stale { border-color: var(--neg); } +.capacity-state--unclassified, +.capacity-state--inventory, +.capacity-state--no-entitlement, +.capacity-state--missing { border-style: dashed; } +.capacity-reason, +.capacity-evidence { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 11px; + white-space: normal; +} +.capacity-layout { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: var(--gap); +} +.capacity-panel { + grid-column: span 6; + min-width: 0; + border: 1px solid var(--grid); + border-radius: var(--radius); + background: var(--card-bg); + padding: 16px; +} +.capacity-panel:first-child, +.capacity-panel:last-child { grid-column: span 12; } +.capacity-panel header h3 { margin: 0; font-size: 13.5px; } +.capacity-panel header p { margin: 3px 0 12px; color: var(--muted); font-size: 11.5px; } +.capacity-heatmap { + width: 100%; + border-spacing: 6px; + border-collapse: separate; + font-size: 12px; +} +.capacity-heatmap caption { + text-align: left; + color: var(--muted); + margin-bottom: 8px; +} +.capacity-heatmap th { + min-width: 120px; + padding: 7px; + text-align: left; + color: var(--muted); + font-size: 11px; +} +.capacity-cell { + min-width: 120px; + border: 1px solid var(--grid); + border-radius: 8px; + padding: 9px; + background: color-mix(in srgb, var(--muted) 5%, var(--card-bg)); +} +.capacity-cell strong, +.capacity-cell span { display: block; overflow-wrap: anywhere; } +.capacity-cell span { margin-top: 3px; color: var(--muted); font-size: 11px; } +.capacity-cell.capacity-state--healthy { border-color: var(--pos); } +.capacity-cell.capacity-state--watch, +.capacity-cell.capacity-state--action { border-color: var(--warn); } +.capacity-cell.capacity-state--exhausted, +.capacity-cell.capacity-state--invalid { border-color: var(--neg); } + +@media (max-width: 900px) { + .capacity-panel { grid-column: span 12; } +} +@media (max-width: 640px) { + body { padding-inline: 12px; } + .capacity-header, + .capacity-home-intro { flex-direction: column; } + .capacity-badge { white-space: normal; } + .capacity-tabs button { white-space: normal; min-width: 132px; } +} diff --git a/.github/extensions/ftk-local-dashboard/public/app.js b/.github/extensions/ftk-local-dashboard/public/app.js new file mode 100644 index 000000000..561789538 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/public/app.js @@ -0,0 +1,2910 @@ +/* FinOps hub dashboard — client renderer. + Dependency-free: KPIs + SVG charts (line, horizontal bar, donut). + Data comes from the extension's loopback /api endpoints. */ + +"use strict"; + +const PALETTE = [ + "#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#ef4444", "#06b6d4", + "#ec4899", "#84cc16", "#f97316", "#6366f1", "#14b8a6", "#a855f7", +]; + +// Shared "missing data" color: muted, never a rotating palette hue, so +// Untagged/Unclassified/blank slices read as "no data" consistently across +// every tab instead of looking like an ordinary category. +const UNKNOWN_COLOR = "var(--muted)"; + +export const CAPACITY_TABS = Object.freeze([ + { id: "home", label: "Home" }, + { id: "app-service", label: "App Service" }, + { id: "azure-ai", label: "Azure AI" }, + { id: "compute", label: "Compute" }, + { id: "azure-sql", label: "Azure SQL" }, + { id: "storage", label: "Storage" }, + { id: "capacity-reservations", label: "Capacity reservations" }, + { id: "premium-ssd-v2", label: "Premium SSD v2" }, +]); + +const state = { + preset: "all", + tab: "overview", + loading: false, + cache: {}, + filters: {}, + capacityClass: "home", + capacitySelections: {}, + revision: 0, +}; +const queryState = { rows: 0, health: "ok", refreshedAt: null, dataset: "Hub database" }; + +/** Human-readable labels for filter dimensions (used in chips). */ +const FILTER_LABELS = { + ServiceName: "Service", + ServiceCategory: "Category", + RegionId: "Region", + x_ResourceGroupName: "Resource group", + SubAccountName: "Subscription", + CommitmentDiscountName: "Commitment", + x_SkuMeterSubcategory: "Meter", +}; + +/* ----------------------------------------------------------------- KQL templates */ + +const PERIOD = "| where ChargePeriodStart >= datetime({start}) and ChargePeriodStart < datetime({end})"; +const NON_PURCH = "| where not(ChargeCategory == 'Purchase' and isnotempty(CommitmentDiscountCategory))"; +const AI_SCOPE = "| where x_SkuMeterSubcategory has 'OpenAI' and x_SkuDescription contains 'Token'"; + +/* eslint-disable max-len */ +const PANEL_KQL = { + "overview-trend": ["Costs()", PERIOD, "| summarize Billed=sum(BilledCost), Effective=sum(EffectiveCost)", " by Month=format_datetime(startofmonth(ChargePeriodStart),'yyyy-MM')", "| order by Month asc"].join("\n"), + "overview-top-services": ["Costs()", PERIOD, "| summarize Cost=sum(EffectiveCost) by ServiceName", "| top 10 by Cost desc"].join("\n"), + "overview-service-category": ["Costs()", PERIOD, "| summarize Cost=sum(EffectiveCost) by ServiceCategory", "| where Cost > 0 | order by Cost desc"].join("\n"), + "overview-top-rgs": ["Costs()", PERIOD, "| where isnotempty(x_ResourceGroupName)", "| summarize Cost=sum(EffectiveCost) by x_ResourceGroupName", "| top 10 by Cost desc"].join("\n"), + "overview-top-regions": ["Costs()", PERIOD, "| where isnotempty(RegionId)", "| summarize Cost=sum(EffectiveCost) by RegionId", "| top 12 by Cost desc"].join("\n"), + "overview-rate-coverage": ["Costs()", PERIOD, "| summarize Cost=sum(EffectiveCost) by PricingCategory"].join("\n"), + "overview-savings": ["Costs()", PERIOD, NON_PURCH, "| extend neg=iff(ListCost<ContractedCost,real(0),ListCost-ContractedCost)", "| extend com=iff(ContractedCost<EffectiveCost,real(0),ContractedCost-EffectiveCost)", "| summarize List=sum(ListCost), Effective=sum(EffectiveCost), Negotiated=sum(neg), Commitment=sum(com)"].join("\n"), + "overview-cost-allocation": ["Costs()", PERIOD, "| extend _t=iff(isnull(Tags) or array_length(bag_keys(Tags))==0,'Untagged','Tagged')", "| summarize Cost=sum(EffectiveCost) by _t"].join("\n"), + "token-trend": ["Costs()", PERIOD, AI_SCOPE, "| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost)", " by Month=format_datetime(startofmonth(ChargePeriodStart),'yyyy-MM')", "| order by Month asc"].join("\n"), + "token-by-model": ["Costs()", PERIOD, AI_SCOPE, "| extend Model=replace_regex(x_SkuDescription,@'^Azure OpenAI[^-]+-\\s*','')", "| extend Model=replace_regex(Model,@'(?i)[\\s-]+(inp|outp|chat|media).*$','')", "| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Model", "| top 12 by Cost desc"].join("\n"), + "token-direction": ["Costs()", PERIOD, AI_SCOPE, "| extend Direction=case(x_SkuDescription has 'Outp','Output',x_SkuDescription contains 'cached','Cached input',x_SkuDescription has 'Inp','Input','Other')", "| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Direction"].join("\n"), + "token-model-table": ["Costs()", PERIOD, AI_SCOPE, "| extend Model=replace_regex(x_SkuDescription,@'^Azure OpenAI[^-]+-\\s*','')", "| extend Model=replace_regex(Model,@'(?i)[\\s-]+(inp|outp|chat|media).*$','')", "| summarize Tokens=sum(ConsumedQuantity), Cost=sum(EffectiveCost) by Model", "| extend CostPer1K=iff(Tokens==0,0.0,Cost/Tokens*1000)", "| top 12 by Cost desc"].join("\n"), + "anomaly-daily": ["let s=datetime({start}); let e=datetime({end});", "Costs()", "| where ChargePeriodStart>=s and ChargePeriodStart<e", "| summarize DC=sum(EffectiveCost) by bin(ChargePeriodStart,1d)", "| make-series Cost=sum(DC) default=0.0 on ChargePeriodStart from s to e step 1d", "| extend (flag,score,baseline)=series_decompose_anomalies(Cost,1.5)", "| mv-expand Day=ChargePeriodStart to typeof(datetime), Cost to typeof(real), flag to typeof(real), baseline to typeof(real)", "| project Day, Cost=toreal(Cost), Flag=toint(flag), Baseline=toreal(baseline)"].join("\n"), + "anomaly-mom": ["Costs()", PERIOD, "| summarize Eff=sum(EffectiveCost) by M=startofmonth(ChargePeriodStart)", "| order by M asc | extend PrevEff=prev(Eff)", "| project Month=format_datetime(M,'yyyy-MM'), EffChangePct=iff(isempty(PrevEff),0.0,(Eff-PrevEff)*100.0/PrevEff), Eff"].join("\n"), + "anomaly-forecast": ["Costs()", PERIOD, "| summarize Eff=sum(EffectiveCost) by bin(ChargePeriodStart,1d)", "| make-series Actual=sum(Eff) default=0.0 on ChargePeriodStart step 1d", "| extend Fc=series_decompose_forecast(Actual,90)", "| mv-expand Day=ChargePeriodStart to typeof(datetime), Actual to typeof(real), Fc to typeof(real)", "| summarize Actual=sum(toreal(Actual)), Forecast=sum(toreal(Fc)) by M=startofmonth(Day)", "| order by M asc | project Month=format_datetime(M,'yyyy-MM'), Actual, Forecast"].join("\n"), + "usage-top-types": ["Costs()", PERIOD, "| where isnotempty(ResourceType)", "| summarize Resources=dcount(ResourceId), Cost=sum(EffectiveCost) by ResourceType", "| top 10 by Cost desc"].join("\n"), + "usage-per-core-series": ["Costs()", PERIOD, "| where x_SkuMeterCategory in ('Virtual Machines','Virtual Machine Licenses') and ChargeCategory=='Usage'", "| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores))", "| extend ch=iff(isnotempty(cores), toreal(cores*ConsumedQuantity), toreal(''))", "| summarize Eff=sum(EffectiveCost), CH=sum(ch) by x_SkuMeterSubcategory", "| where CH > 100 | extend PerCore=Eff/CH | top 10 by Eff desc"].join("\n"), + "usage-storage-tiers": ["Costs()", PERIOD, "| where ServiceCategory=='Storage' and ChargeCategory=='Usage'", "| extend Tier=case(x_SkuTier has_any('Hot','Standard','Premium'),'Frequent',x_SkuTier has_any('Cool','Cold','Archive'),'Infrequent','Unclassified')", "| summarize Cost=sum(EffectiveCost) by Tier", "| where Cost > 0 | order by Cost desc"].join("\n"), + "rate-savings": ["Costs()", PERIOD, NON_PURCH, "| extend neg=iff(ListCost<ContractedCost,real(0),ListCost-ContractedCost)", "| extend com=iff(ContractedCost<EffectiveCost,real(0),ContractedCost-EffectiveCost)", "| extend tot=iff(ListCost<EffectiveCost,real(0),ListCost-EffectiveCost)", "| summarize List=sum(ListCost), Effective=sum(EffectiveCost), Negotiated=sum(neg), Commitment=sum(com), Total=sum(tot)"].join("\n"), + "rate-commit-util": ["Costs()", PERIOD, "| where isnotempty(CommitmentDiscountId)", NON_PURCH, "| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost)"].join("\n"), + "rate-core-hours": ["Costs()", PERIOD, "| extend cores=toint(coalesce(x_SkuDetails.VCPUs, x_SkuDetails.vCores, 0))", "| extend ch=iff(cores>0, cores*ConsumedQuantity, toreal(''))", "| extend t=iff(isempty(CommitmentDiscountType),'On Demand',CommitmentDiscountType)", "| summarize CoreHours=sum(ch) by t", "| where CoreHours > 0 | order by CoreHours desc"].join("\n"), + "rate-underutil": ["Costs()", PERIOD, "| where isnotempty(CommitmentDiscountName)", NON_PURCH, "| summarize Unused=sumif(EffectiveCost,CommitmentDiscountStatus=='Unused'), Total=sum(EffectiveCost) by CommitmentDiscountName", "| where Unused > 0 | top 10 by Unused desc"].join("\n"), + "alloc-hierarchy": ["Costs()", PERIOD, "| extend Org=tostring(Tags['org']), Project=tostring(Tags['Project']), Env=tostring(Tags['env'])", "| summarize Cost=sum(EffectiveCost) by Org, Project, Env", "| where Cost > 0 | top 12 by Cost desc"].join("\n"), + "alloc-tagging": ["Costs()", PERIOD, "| extend _t=iff(isnull(Tags) or array_length(bag_keys(Tags))==0,'Untagged','Tagged')", "| summarize Cost=sum(EffectiveCost) by _t"].join("\n"), + "alloc-tag-keys": ["Costs()", PERIOD, "| mv-expand k=bag_keys(Tags) to typeof(string)", "| where isnotempty(k) and k !in ('ftk-tool','ftk-version','cm-resource-parent','costanalysis-parent') and not(k startswith 'aks-managed-')", "| summarize Cost=sum(EffectiveCost) by k", "| top 12 by Cost desc"].join("\n"), + "alloc-by-subscription": ["Costs()", PERIOD, "| where isnotempty(SubAccountName)", "| summarize Cost=sum(EffectiveCost) by SubAccountName", "| top 10 by Cost desc"].join("\n"), +}; +/* eslint-enable max-len */ + +// Panel id -> the name of the query that produced it. Used to look up the KQL +// the server actually executed, which keeps the panel "KQL" dialog honest +// without a second hand-maintained copy of every query. +const PANEL_QUERY = { + "ai-capability-trend": "capabilityTrend", + "ai-capability": "capability", + "ai-by-service": "byService", + "ai-token-demand": "monthly", + "ai-cpmt-trend": "monthly", + "ai-model-bench": "modelBench", + "ai-direction": "direction", + "ai-ml-gpu": "mlGpu", + "ai-ml-unit": "mlUnit", + "ai-search": "search", + "ai-cognitive": "cognitive", + "ai-allocation": "allocation", + "ai-by-owner": "byOwner", + "ai-posture": "posture", + "ai-drivers": "drivers", +}; + +let _kqlPanelId = null; +let _loadAbort = null; + +/* ------------------------------------------------------------------ filter management */ + +function filterKey() { + const entries = Object.entries(state.filters) + .filter(([, arr]) => arr && arr.length > 0) + .sort(([a], [b]) => a.localeCompare(b)); + return entries.length > 0 ? "|" + JSON.stringify(entries) : ""; +} + +function cacheKey() { + if (state.tab === "capacity") { + return `${state.capacityClass}|${JSON.stringify(state.capacitySelections || {})}`; + } + return state.preset + filterKey(); +} + +function toggleFilter(dim, val) { + const arr = state.filters[dim] || []; + const idx = arr.indexOf(val); + if (idx >= 0) { + const next = arr.filter((v) => v !== val); + if (next.length === 0) delete state.filters[dim]; + else state.filters[dim] = next; + } else { + state.filters[dim] = [...arr, val]; + } + renderFilterBar(); + void publishCanvasState({ filters: state.filters }); + load(); +} + +function clearFilters() { + state.filters = {}; + renderFilterBar(); + void publishCanvasState({ filters: state.filters }); + load(); +} + +// The tab strip scrolls horizontally below ~1000px and never moved on its own, +// so deep-linking to a tab late in the strip left the nav looking like the first +// tab was still selected. Called from every path that marks a tab active. +function revealActiveTab() { + const active = el("tabs")?.querySelector("button[data-tab].active"); + if (active && active.scrollIntoView) active.scrollIntoView({ inline: "nearest", block: "nearest" }); +} + +function syncCanvasControls() { + [...el("preset").querySelectorAll("button[data-preset]")].forEach((button) => { + button.classList.toggle("active", button.dataset.preset === state.preset); + }); + [...el("tabs").querySelectorAll("button[data-tab]")].forEach((button) => { + const active = button.dataset.tab === state.tab; + button.classList.toggle("active", active); + button.setAttribute("aria-selected", active ? "true" : "false"); + }); + revealActiveTab(); + const isTool = TOOL_TABS.has(state.tab); + const isCapacity = state.tab === "capacity"; + el("preset").hidden = isTool || isCapacity; + el("refresh").hidden = isTool; + el("app-footer").hidden = isTool; + renderFilterBar(); +} + +function applySharedCanvasState(next, options = {}) { + if (!next || !Number.isInteger(next.revision) || next.revision < state.revision) return; + const previousTab = state.tab; + const changed = next.tab !== state.tab || next.preset !== state.preset || + next.capacityClass !== state.capacityClass || + JSON.stringify(next.capacitySelections || {}) !== JSON.stringify(state.capacitySelections) || + JSON.stringify(next.filters || {}) !== JSON.stringify(state.filters); + state.tab = next.tab; + state.preset = next.preset; + state.filters = next.filters || {}; + state.capacityClass = next.capacityClass || "home"; + state.capacitySelections = next.capacitySelections || {}; + state.revision = next.revision; + syncCanvasControls(); + if (previousTab === "monaco" && state.tab !== "monaco") disposeMonacoEditor(); + if (changed || options.forceReload) { + if (options.forceReload) state.cache = {}; + const hash = state.tab === "capacity" + ? `#tab=capacity&capacity=${state.capacityClass}` + : `#tab=${state.tab}`; + history.replaceState({ tab: state.tab, capacityClass: state.capacityClass }, "", hash); + load(); + } +} + +async function publishCanvasState(patch) { + const send = async (expectedRevision) => { + const response = await fetch("/api/session-state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...patch, expectedRevision }), + }); + return { response, body: await response.json() }; + }; + + try { + let result = await send(state.revision); + if (result.response.status === 409 && result.body.state) { + const merged = { ...result.body.state, ...patch, revision: result.body.state.revision }; + applySharedCanvasState(merged); + result = await send(result.body.state.revision); + } + if (!result.response.ok || result.body.error) throw new Error(result.body.error || "Could not share canvas state."); + applySharedCanvasState(result.body); + } catch (err) { + console.error("[ftk-dashboard] state synchronization failed:", err); + } +} + +async function pollCanvasState() { + try { + const next = await fetch("/api/session-state").then((response) => response.json()); + if (!Number.isInteger(next.revision) || next.revision <= state.revision) return; + const previous = window.__cfg || {}; + const config = await fetch("/api/config").then((response) => response.json()); + const connectionChanged = config.clusterUri !== previous.clusterUri || config.database !== previous.database; + window.__cfg = config; + applySharedCanvasState(next, { forceReload: connectionChanged }); + } catch { + // The extension may be restarting; the next poll will reconnect. + } +} + +function renderFilterBar() { + const bar = document.getElementById("filter-bar"); + const chips = document.getElementById("filter-chips"); + if (!bar || !chips) return; + const entries = state.tab === "capacity" + ? [] + : Object.entries(state.filters).filter(([, arr]) => arr && arr.length > 0); + if (entries.length === 0) { + bar.hidden = true; + chips.innerHTML = ""; + return; + } + bar.hidden = false; + chips.innerHTML = entries.flatMap(([dim, vals]) => + vals.map((val) => { + const label = FILTER_LABELS[dim] || dim; + return `<span class="filter-chip" data-dim="${esc(dim)}" data-val="${esc(val)}">` + + `<span class="chip-label"><strong>${esc(label)}</strong> ${esc(val)}</span>` + + `<button class="chip-remove" data-dim="${esc(dim)}" data-val="${esc(val)}" ` + + `aria-label="Remove filter ${esc(label)}: ${esc(val)}" type="button">×</button>` + + `</span>`; + }) + ).join(""); +} + +/* ------------------------------------------------------------------ utils */ + +function fmtMoney(n) { + if (n == null || isNaN(n)) return "$0"; + const sign = n < 0 ? "-" : ""; + const a = Math.abs(n); + if (a >= 1e6) return `${sign}$${(a / 1e6).toFixed(2)}M`; + if (a >= 1e3) return `${sign}$${(a / 1e3).toFixed(1)}K`; + return `${sign}$${a.toFixed(a < 100 ? 2 : 0)}`; +} +function fmtMoneyFull(n) { + if (n == null || isNaN(n)) return "$0"; + return n.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }); +} +function fmtPct(x, d = 1) { + if (x == null || isNaN(x)) return "—"; + return `${(x * 100).toFixed(d)}%`; +} +function fmtInt(n) { + return (n ?? 0).toLocaleString("en-US"); +} +function fmtTokens(n) { + if (n == null || isNaN(n)) return "0"; + const a = Math.abs(n); + if (a >= 1e9) return `${(n / 1e9).toFixed(2)}B`; + if (a >= 1e6) return `${(n / 1e6).toFixed(1)}M`; + if (a >= 1e3) return `${(n / 1e3).toFixed(1)}K`; + return `${Math.round(n)}`; +} +function fmtPerM(costPer1K) { + // costPer1K is $ per 1,000 tokens -> show $ per 1,000,000 tokens + const v = (costPer1K || 0) * 1000; + return `$${v.toFixed(2)}`; +} +// Unit rates span many orders of magnitude ($75 per 1K core-hours down to +// $0.00015 per VM-hour), so scale precision to the value. fmtMoneyFull rounds +// to whole dollars and would collapse every sub-dollar rate to "$0". +export function fmtRate(n) { + if (n == null || isNaN(n)) return "—"; + if (n === 0) return "$0.00"; + const abs = Math.abs(n); + if (abs >= 1) return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + if (abs >= 0.01) return `$${n.toFixed(3)}`; + return `$${Number(n.toPrecision(2))}`; +} +// Consumed quantities are not integers (VM-hours, request units), and +// toLocaleString's 3-decimal default renders "22.033" one row above "22,247" — +// two numbers three orders of magnitude apart distinguished only by the +// separator glyph. Abbreviate above 1K so the magnitude is unambiguous. +export function fmtQty(n) { + if (n == null || isNaN(n)) return "—"; + const a = Math.abs(n); + if (a >= 1e6) return `${(n / 1e6).toFixed(2)}M`; + if (a >= 1e3) return `${(n / 1e3).toFixed(1)}K`; + if (a === 0) return "0"; + return `${Number(n.toFixed(2))}`; +} +// A money column is read as a right-aligned stack, so every cell in it must +// share one precision. Pick that precision once from the column's own maximum: +// large columns drop cents (nobody reads cents next to $178,528), small columns +// keep them. A nonzero value too small for the chosen precision renders as a +// floor marker rather than "$0.00", which would read as missing data. +export function moneyColumn(rows, ...keys) { + const vals = []; + for (const r of rows || []) for (const k of keys) { + const v = Math.abs(+r[k]); if (v > 0 && isFinite(v)) vals.push(v); + } + const dp = vals.length && Math.max(...vals) >= 1000 ? 0 : 2; + return fixedDollars(dp); +} + +// Rates span far more orders of magnitude than money ($0.0004 to $74 in one +// table), so they need their own ladder — but still one precision per column, +// because a reader compares cells down a column, not against their magnitude. +export function rateColumn(rows, ...keys) { + const vals = []; + for (const r of rows || []) for (const k of keys) { + const v = Math.abs(+r[k]); if (v > 0 && isFinite(v)) vals.push(v); + } + const max = vals.length ? Math.max(...vals) : 1; + const dp = max >= 1 ? 2 : max >= 0.01 ? 3 : 4; + return fixedDollars(dp); +} + +// Values below half a unit would round to "$0.00" and read as free, so they get +// a floor marker instead. +function fixedDollars(dp) { + const unit = Math.pow(10, -dp); + return (n) => { + if (n == null || isNaN(n)) return "—"; + if (n === 0) return `$${(0).toFixed(dp)}`; + if (Math.abs(n) < unit / 2) return `<$${unit.toFixed(dp)}`; + const body = Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: dp, maximumFractionDigits: dp }); + return `${n < 0 ? "-" : ""}$${body}`; + }; +} +// Axis ticks share one scale, so they need one precision — unlike a cell, where +// fmtRate scales precision to the individual value. Mixing them puts "$0.500" +// directly above "$1.00" on the same axis. +function axisRate(n) { + if (n == null || isNaN(n)) return "—"; + return `$${Number(n).toFixed(2)}`; +} + +export function fmtShare(x, d = 1) { + // A row with visible nonzero cost must never report "0.0%" — that reads as a + // broken calculation and makes the column visibly fail to sum to 100%. + if (x == null || isNaN(x)) return "—"; + const floor = 1 / Math.pow(10, d + 2); + if (x > 0 && x < floor) return `<${(floor * 100).toFixed(d)}%`; + return `${(x * 100).toFixed(d)}%`; +} +function fmtMonth(ym) { + // "2025-04" -> "Apr ’25" + if (!ym || typeof ym !== "string") return String(ym ?? "—"); + const [y, m] = ym.split("-"); + if (!y || !m) return ym; + const names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + return `${names[+m - 1] || m} '${y.slice(2)}`; +} +function fmtDayRange(min, max) { + if (!min || !max) return "—"; + const f = (s) => { + const d = new Date(s); + if (isNaN(d)) return String(s); + return d.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" }); + }; + return `${f(min)} – ${f(max)}`; +} +function esc(s) { + return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +} +function trunc(s, n) { + s = String(s ?? ""); + if (s.length <= n) return s; + // Middle-ellipsis: keep a short tail visible so structurally similar long + // identifiers (e.g. two reservation IDs differing only in their suffix) + // don't collide into the same truncated string. + const keepEnd = Math.min(8, Math.floor(n * 0.35)); + const keepStart = Math.max(1, n - keepEnd - 1); + return `${s.slice(0, keepStart)}…${s.slice(-keepEnd)}`; +} +function el(id) { return document.getElementById(id); } +function fmtRelativeTime(date) { + if (!date) return "—"; + const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + const diffSec = (date - Date.now()) / 1000; + const abs = Math.abs(diffSec); + if (abs < 60) return rtf.format(Math.round(diffSec), "second"); + if (abs < 3600) return rtf.format(Math.round(diffSec / 60), "minute"); + if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), "hour"); + return rtf.format(Math.round(diffSec / 86400), "day"); +} +function svgEl(w, h, body, label = "") { + const ariaAttr = label ? ` aria-label="${esc(label)}"` : ""; + return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" role="img"${ariaAttr}>${body}</svg>`; +} + +/* ----------------------------------------------------------------- charts */ + +// Shared single-left-axis gridline + y-tick-label renderer, used by every +// chart with one money-scaled y-axis (lineChart, anomalyChart, forecastChart). +// tokenTrendChart keeps its own dual-axis (token + cost) tick renderer — a +// different concept (two scales, two tick labels per line), not merged here. +function yAxisGrid(m, W, ih, yMax, ticks, valFmt) { + let g = ""; + for (let t = 0; t <= ticks; t++) { + const val = (yMax / ticks) * t; + const yy = m.t + ih - (ih / ticks) * t; + g += `<line class="grid-line" x1="${m.l}" y1="${yy}" x2="${W - m.r}" y2="${yy}"/>`; + // The zero tick otherwise takes a different branch of the money/token + // formatters ("$0.00" beneath "$4.3K"), leaving one tick in a format the + // rest of the axis doesn't share. + const label = t === 0 ? valFmt(0).replace(/\.0+\b/, "") : valFmt(val); + g += `<text class="tick" x="${m.l - 8}" y="${yy + 4}" text-anchor="end">${label}</text>`; + } + return g; +} + +// Round an axis ceiling up to a 1 / 2 / 2.5 / 5 x 10^n step so gridlines land on +// values a reader can actually use. Always rounds up, so a series can never +// exceed the plotted maximum. +export function niceMax(v, ticks = 4) { + if (!(v > 0) || !isFinite(v)) return 1; + const raw = v / ticks; + const mag = Math.pow(10, Math.floor(Math.log10(raw))); + const norm = raw / mag; + const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag; + return step * ticks; +} + +// Shared index-thinned x-axis month-label renderer: shows a label at evenly +// spaced indices (max ~12) plus always the last row, for any chart whose rows +// run left-to-right one-per-month. anomalyChart uses a different, month- +// change-detection variant over daily rows and keeps its own logic. +function xAxisMonthLabels(rows, xFn, H, monthField = "Month") { + let g = ""; + const n = rows.length; + const step = Math.ceil(n / 12); + // Emit the evenly-stepped indices, then append the final month only when it + // isn't already on the grid *and* it clears the previous label by a full step. + // Otherwise the last two ticks crowd at half the spacing of every other pair. + const idx = []; + for (let i = 0; i < n; i += step) idx.push(i); + const last = n - 1; + if (last >= 0 && idx[idx.length - 1] !== last) { + if (last - idx[idx.length - 1] >= step) idx.push(last); + else idx[idx.length - 1] = last; + } + idx.forEach((i) => { + g += `<text class="tick" x="${xFn(i)}" y="${H - 12}" text-anchor="middle">${esc(fmtMonth(rows[i][monthField]))}</text>`; + }); + return g; +} + +function lineChart(rows) { + // rows: [{Month, Billed, Effective}] + // Flatter aspect ratio (vs. 280 previously): a ~15-point monthly line has + // low vertical information density, so a wide-but-short viewBox avoids the + // chart dominating the tab when rendered at panel width. + const W = 760, H = 200; + const m = { l: 56, r: 18, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + if (!rows || rows.length === 0) return emptyChart(W, H, "Monthly cost trend — no data"); + const max = Math.max(...rows.map((r) => Math.max(r.Billed || 0, r.Effective || 0)), 1); + const yMax = max * 1.12; + const n = rows.length; + const x = (i) => m.l + (n === 1 ? iw / 2 : (i / (n - 1)) * iw); + const y = (v) => m.t + ih - (v / yMax) * ih; + + let g = ""; + // gridlines + y ticks + g += yAxisGrid(m, W, ih, yMax, 4, fmtMoney); + // x labels (thin out if crowded) + g += xAxisMonthLabels(rows, x, H); + // area under effective + const ptsE = rows.map((r, i) => `${x(i)},${y(r.Effective || 0)}`); + const area = `M${m.l},${y(0)} L${ptsE.join(" L")} L${x(n - 1)},${y(0)} Z`; + g += `<path d="${area}" fill="${PALETTE[0]}" fill-opacity="0.10"/>`; + // billed line (muted, dashed) + const ptsB = rows.map((r, i) => `${x(i)},${y(r.Billed || 0)}`).join(" L"); + g += `<path d="M${ptsB}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.7"/>`; + // effective line + g += `<path d="M${ptsE.join(" L")}" fill="none" stroke="${PALETTE[0]}" stroke-width="2.5"/>`; + // dots + hover titles + rows.forEach((r, i) => { + g += `<circle class="bar" tabindex="0" cx="${x(i)}" cy="${y(r.Effective || 0)}" r="3.2" fill="${PALETTE[0]}"><title>${esc(fmtMonth(r.Month))}\nEffective ${fmtMoneyFull(r.Effective)}\nBilled ${fmtMoneyFull(r.Billed)}`; + }); + const legend = legendHtml([ + { label: "Effective cost", color: PALETTE[0] }, + { label: "Billed cost", color: "var(--muted)" }, + ]); + return svgEl(W, H, g, "Monthly cost trend — billed vs effective cost") + legend; +} + +function hbar(rows, nameKey, valKey, opts = {}) { + const data = (rows || []).map((r) => ({ name: String(r[nameKey] ?? "—"), val: +r[valKey] || 0 })) + .filter((r) => r.val > 0); + if (data.length === 0) return `

No data in range.

`; + const max = Math.max(...data.map((d) => d.val), 1); + const total = data.reduce((s, d) => s + d.val, 0); + const rowH = 30, padR = 64, nameW = opts.nameW ?? 142; + // The name budget is a character count, so it has to track nameW or wide + // panels truncate names that had ~100 viewBox units of free gutter beside them. + const nameChars = opts.nameChars ?? Math.max(12, Math.floor(nameW / 7.1)); + const W = 540, H = data.length * rowH + 6; + const barX = nameW + 8, barW = W - barX - padR; + const valFmt = opts.valFmt || fmtMoney; + // filterDim: by default use nameKey; pass null to opt-out of filtering + const filterDim = "filterDim" in opts ? opts.filterDim : nameKey; + const activeVals = filterDim && state.filters[filterDim]; + const hasFilter = activeVals && activeVals.length > 0; + let g = ""; + data.forEach((d, i) => { + const yy = i * rowH + 4; + const cy = yy + rowH / 2; + const w = Math.max(2, (d.val / max) * barW); + const color = opts.color || PALETTE[i % PALETTE.length]; + const pct = total > 0 ? (d.val / total) : 0; + const isSelected = hasFilter && activeVals.includes(d.name); + const isDimmed = hasFilter && !isSelected; + let cls = "hbar-row"; + if (filterDim) cls += " hbar-filterable"; + if (isSelected) cls += " hbar-selected"; + if (isDimmed) cls += " hbar-dimmed"; + const isTruncated = d.name.length > nameChars; + const dimAttr = filterDim ? ` data-filter-dim="${esc(filterDim)}" data-filter-val="${esc(d.name)}"` : ""; + const interactiveAttrs = filterDim + ? ` tabindex="0" role="button" aria-pressed="${isSelected ? 'true' : 'false'}" aria-label="Filter by ${esc(d.name)}, ${valFmt(d.val)}"` + : ` tabindex="0" aria-label="${esc(d.name)}, ${valFmt(d.val)}"`; + g += ``; + g += `${esc(trunc(d.name, nameChars))}${esc(d.name)}`; + g += `${esc(d.name)}\n${fmtMoneyFull(d.val)} · ${fmtPct(pct)}`; + g += `${valFmt(d.val)}`; + g += ``; + }); + return svgEl(W, H, g, opts.label || ""); +} + +function donut(slices, opts = {}) { + const data = (slices || []).filter((s) => (+s.value || 0) > 0); + const total = data.reduce((s, d) => s + (+d.value || 0), 0); + if (total <= 0) return `

No data in range.

`; + const size = 180, cx = size / 2, cy = size / 2, R = 80, r = 50; + let a0 = 0, g = ""; + if (data.length === 1) { + g += `${esc(data[0].label)}\n${fmtMoneyFull(data[0].value)} · 100%`; + } else { + // Give near-zero slices a minimum visible arc so they aren't rendered as + // an invisible sliver, mirroring hbar()'s Math.max(2, ...) width floor. + // The angle deficit is subtracted from the single largest slice so the + // total stays exactly 360°. + const minAngle = 4; + const angles = data.map((d) => (d.value / total) * 360); + let deficit = 0; + const boosted = angles.map((a) => { + if (a < minAngle) { deficit += minAngle - a; return minAngle; } + return a; + }); + if (deficit > 0) { + const maxIdx = boosted.reduce((best, a, i) => (a > boosted[best] ? i : best), 0); + boosted[maxIdx] = Math.max(minAngle, boosted[maxIdx] - deficit); + } + data.forEach((d, i) => { + const frac = d.value / total; + const a1 = a0 + boosted[i]; + g += `${esc(d.label)}\n${fmtMoneyFull(d.value)} · ${fmtPct(frac)}`; + a0 = a1; + }); + } + const centerBig = opts.centerBig ?? fmtMoney(total); + const centerSmall = opts.centerSmall ?? "total"; + g += `${esc(centerBig)}`; + g += `${esc(centerSmall)}`; + const legend = legendHtml(data.map((d) => ({ + label: d.label, color: d.color, isUnknown: d.isUnknown, + value: opts.valueFmt ? opts.valueFmt(d) : `${fmtMoney(d.value)} · ${fmtPct(d.value / total)}`, + }))); + return `
${svgEl(size, size, g, opts.label || "")}
${legend}
`; +} + +function donutSeg(cx, cy, R, r, a0, a1) { + const polar = (rad, ang) => { + const a = ((ang - 90) * Math.PI) / 180; + return [cx + rad * Math.cos(a), cy + rad * Math.sin(a)]; + }; + const large = a1 - a0 > 180 ? 1 : 0; + const [x0, y0] = polar(R, a0), [x1, y1] = polar(R, a1); + const [x2, y2] = polar(r, a1), [x3, y3] = polar(r, a0); + return `M${x0} ${y0} A${R} ${R} 0 ${large} 1 ${x1} ${y1} L${x2} ${y2} A${r} ${r} 0 ${large} 0 ${x3} ${y3} Z`; +} + +function legendHtml(items) { + return `
${items.map((it) => + `${esc(it.label)}${ + it.value ? `${esc(it.value)}` : ""}`).join("")}
`; +} + +// Inline swatch for raw table cells (outside donut/hbar). isUnknown renders +// the shared dashed/muted "no data" treatment instead of a rotating palette +// color, matching legendHtml's isUnknown handling. +function swatchHtml(color, isUnknown = false) { + return ``; +} + +// Generic data table. cols: [{label, align?, get:(row,i)=>htmlString}]. rows: any[]. +function tableHtml(cols, rows, emptyMsg = "No data in range.") { + if (!rows || rows.length === 0) return `

${esc(emptyMsg)}

`; + const head = cols.map((c) => `${esc(c.label)}`).join(""); + const body = rows.map((r, i) => `${cols.map((c) => `${c.get(r, i)}`).join("")}`).join(""); + return `${head}${body}
`; +} + +// Shared "cost breakdown" list row: an optional color swatch, a label, and a +// money value. Used by the Overview and Rate tabs' savings-breakdown panels — +// same concept, same markup, previously implemented twice independently. +function costBreakdownRow(label, val, accent) { + return `
+ ${ + accent ? `` : ""}${esc(label)} + ${fmtMoney(val)}
`; +} + +// rows: [{label, val, accent?}]. footerLabel/footerValue render an optional +// trailing summary stat (e.g. "Effective savings rate — 42%") below the rows. +function costBreakdownTable(rows, footerLabel, footerValue) { + const footer = footerLabel + ? `
+ ${esc(footerLabel)} + ${footerValue} +
` + : ""; + return `
${rows.map((r) => costBreakdownRow(r.label, r.val, r.accent)).join("")}${footer}
`; +} + +function emptyChart(W, H, label = "No data") { + return svgEl(W, H, `No data`, label); +} + + +function tokenTrendChart(rows) { + // rows: [{Month, Tokens, Cost}] — bars = token volume (left axis), line = AI cost (right axis) + // Flatter aspect ratio (vs. 280 previously) — same rationale as lineChart: + // ~15 monthly points don't need 280 units of vertical resolution. + const W = 760, H = 200; + const m = { l: 56, r: 58, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + if (!rows || rows.length === 0) return emptyChart(W, H, "AI token volume and cost trend — no data"); + const tokMax = Math.max(...rows.map((r) => r.Tokens || 0), 1) * 1.14; + const costMax = Math.max(...rows.map((r) => r.Cost || 0), 1) * 1.14; + const n = rows.length; + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const yTok = (v) => m.t + ih - (v / tokMax) * ih; + const yCost = (v) => m.t + ih - (v / costMax) * ih; + const bw = Math.max(4, (iw / n) * 0.62); + + let g = ""; + const ticks = 4; + for (let t = 0; t <= ticks; t++) { + const yy = m.t + ih - (ih / ticks) * t; + g += ``; + g += `${fmtTokens((tokMax / ticks) * t)}`; + g += `${fmtMoney((costMax / ticks) * t)}`; + } + // token bars (left axis) + rows.forEach((r, i) => { + const top = yTok(r.Tokens || 0); + const h = Math.max(0, m.t + ih - top); + g += `${esc(fmtMonth(r.Month))}\n${fmtTokens(r.Tokens)} tokens\n${fmtMoneyFull(r.Cost)}`; + }); + // cost line (right axis) + const pts = rows.map((r, i) => `${cx(i)},${yCost(r.Cost || 0)}`); + g += ``; + rows.forEach((r, i) => { + g += `${esc(fmtMonth(r.Month))}\n${fmtMoneyFull(r.Cost)}`; + }); + // x labels + g += xAxisMonthLabels(rows, cx, H); + const legend = legendHtml([ + { label: "Token volume", color: PALETTE[2] }, + { label: "AI effective cost", color: PALETTE[3] }, + ]); + return svgEl(W, H, g, "AI token volume and cost trend") + legend; +} + +// Fixed capability colors so the stacked chart, its legend, and the capability +// table all agree on which hue means which workload. A rotating index would +// re-colour a capability whenever the estate mix changes month to month. +const AI_CAPABILITY_COLORS = { + "GPU / accelerated compute": PALETTE[9], + "Foundation models (LLM)": PALETTE[2], + "AI Search / retrieval": PALETTE[5], + "ML platform & compute": PALETTE[0], + "ML / analytics platform": PALETTE[1], + "Cognitive services": PALETTE[3], + "Bot & agents": PALETTE[6], + "Other AI/ML": PALETTE[7], +}; +const aiColor = (capability) => AI_CAPABILITY_COLORS[capability] ?? UNKNOWN_COLOR; + +function aiCapabilityChart(rows) { + // rows: [{Month, Capability, Cost}] — stacked columns, one stack per month. + const W = 760, H = 200; + const m = { l: 56, r: 18, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + if (!rows || rows.length === 0) return emptyChart(W, H, "AI spend by capability over time — no data"); + + const months = [...new Set(rows.map((r) => r.Month))].sort(); + // Order the stack by total spend so the dominant capability sits at the base + // and the thin slices stay adjacent to the axis labels. + const totals = new Map(); + rows.forEach((r) => totals.set(r.Capability, (totals.get(r.Capability) ?? 0) + (r.Cost || 0))); + const caps = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([c]) => c); + const at = new Map(rows.map((r) => [`${r.Month}|${r.Capability}`, r.Cost || 0])); + + const monthTotals = months.map((mo) => caps.reduce((s, c) => s + (at.get(`${mo}|${c}`) ?? 0), 0)); + const yMax = niceMax(Math.max(...monthTotals, 1) * 1.02, 4); + const n = months.length; + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const bw = Math.max(4, (iw / n) * 0.62); + + let g = yAxisGrid(m, W, ih, yMax, 4, fmtMoney); + months.forEach((mo, i) => { + let acc = 0; + caps.forEach((c) => { + const v = at.get(`${mo}|${c}`) ?? 0; + if (v <= 0) return; + const h = (v / yMax) * ih; + const yTop = m.t + ih - ((acc + v) / yMax) * ih; + acc += v; + g += `${esc(fmtMonth(mo))}\n${esc(c)}\n${fmtMoneyFull(v)}`; + }); + }); + g += xAxisMonthLabels(months.map((mo) => ({ Month: mo })), cx, H); + const legend = legendHtml(caps.map((c) => ({ label: c, color: aiColor(c) }))); + return svgEl(W, H, g, "AI spend by capability over time") + legend; +} + +function monthAreaChart(rows, opts) { + // rows: [{Month, }] — filled area with a stroked top edge. + const { valueKey, color = PALETTE[0], valFmt = fmtMoney, tipFmt = valFmt, label = "Trend" } = opts; + const W = 760, H = 200; + const m = { l: 56, r: 18, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + const data = (rows || []).filter((r) => isFinite(r[valueKey])); + if (data.length === 0) return emptyChart(W, H, `${label} — no data`); + + const yMax = niceMax(Math.max(...data.map((r) => r[valueKey]), 1) * 1.02, 4); + const n = data.length; + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const y = (v) => m.t + ih - (v / yMax) * ih; + + let g = yAxisGrid(m, W, ih, yMax, 4, valFmt); + const pts = data.map((r, i) => `${cx(i)},${y(r[valueKey])}`); + const base = m.t + ih; + g += ``; + g += ``; + data.forEach((r, i) => { + g += `${esc(fmtMonth(r.Month))}\n${tipFmt(r[valueKey])}`; + }); + g += xAxisMonthLabels(data, cx, H); + return svgEl(W, H, g, label); +} + +function anomalyChart(rows) { + // rows: [{Day, Cost, Flag, Baseline}] + // Flattened to match lineChart/tokenTrendChart's aspect ratio (was 280) so + // this full-width daily chart doesn't read as taller/heavier than the other + // trend charts across tabs — daily granularity needs horizontal, not + // vertical, resolution. + const W = 760, H = 200; + const m = { l: 56, r: 18, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + if (!rows || rows.length === 0) return emptyChart(W, H, "Daily anomaly detection — no data"); + const max = Math.max(...rows.map((r) => Math.max(r.Cost || 0, r.Baseline || 0)), 1) * 1.12; + const n = rows.length; + const x = (i) => m.l + (n === 1 ? iw / 2 : (i / (n - 1)) * iw); + const y = (v) => m.t + ih - (v / max) * ih; + let g = ""; + g += yAxisGrid(m, W, ih, max, 4, fmtMoney); + // month x labels + let lastMonth = ""; + rows.forEach((r, i) => { + const mo = String(r.Day).slice(0, 7); + if (mo !== lastMonth) { + lastMonth = mo; + g += `${esc(fmtMonth(mo))}`; + } + }); + // baseline (dashed) + cost line + const base = rows.map((r, i) => `${x(i)},${y(r.Baseline || 0)}`).join(" L"); + g += ``; + const cost = rows.map((r, i) => `${x(i)},${y(r.Cost || 0)}`).join(" L"); + g += ``; + // anomaly markers + rows.forEach((r, i) => { + if (r.Flag !== 0) { + const up = r.Flag > 0; + g += `${esc(String(r.Day).slice(0, 10))}\n${fmtMoneyFull(r.Cost)} (${up ? "spike" : "drop"})\nbaseline ${fmtMoneyFull(r.Baseline)}`; + } + }); + const legend = legendHtml([ + { label: "Daily effective cost", color: PALETTE[0] }, + { label: "Expected baseline", color: "var(--muted)" }, + { label: "Spike", color: PALETTE[4] }, + { label: "Drop", color: PALETTE[1] }, + ]); + return svgEl(W, H, g, "Daily anomaly detection — cost vs expected baseline") + legend; +} + +function momBars(rows) { + // rows: [{Month, EffChangePct}] — diverging bars (cost up = red, down = green) + const W = 760, H = 240; + const m = { l: 44, r: 14, t: 14, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + const data = (rows || []).filter((r) => isFinite(r.EffChangePct)); + if (data.length === 0) return emptyChart(W, H, "Month-over-month effective cost change — no data"); + const maxAbs = Math.max(...data.map((r) => Math.abs(r.EffChangePct)), 5); + const n = data.length; + const y0 = m.t + ih / 2; // zero line + const cx = (i) => m.l + ((i + 0.5) / n) * iw; + const bw = Math.max(5, (iw / n) * 0.6); + const yScale = (v) => (v / maxAbs) * (ih / 2); + let g = ``; + data.forEach((r, i) => { + const v = r.EffChangePct; + const h = Math.abs(yScale(v)); + const yTop = v >= 0 ? y0 - h : y0; + const color = v > 0 ? PALETTE[4] : PALETTE[1]; + g += `${esc(fmtMonth(r.Month))}\n${v > 0 ? "+" : ""}${v.toFixed(1)}%`; + }); + g += xAxisMonthLabels(data, cx, H); + return svgEl(W, H, g, "Month-over-month effective cost change"); +} + +function forecastChart(rows, splitMonth) { + // rows: [{Month, Actual, Forecast}] — actual solid up to splitMonth, forecast dashed onward + const W = 760, H = 280; + const m = { l: 56, r: 18, t: 16, b: 34 }; + const iw = W - m.l - m.r, ih = H - m.t - m.b; + if (!rows || rows.length === 0) return emptyChart(W, H, "Cost forecast — no data"); + const max = Math.max(...rows.map((r) => Math.max(r.Actual || 0, r.Forecast || 0)), 1) * 1.12; + const n = rows.length; + const x = (i) => m.l + (n === 1 ? iw / 2 : (i / (n - 1)) * iw); + const y = (v) => m.t + ih - (v / max) * ih; + let g = ""; + g += yAxisGrid(m, W, ih, max, 4, fmtMoney); + const splitIdx = rows.findIndex((r) => r.Month >= splitMonth); + const sIdx = splitIdx < 0 ? n - 1 : splitIdx; + // shaded forecast region + g += ``; + // actual line up to split + const actualPts = rows.slice(0, sIdx + 1).map((r, i) => `${x(i)},${y(r.Actual || 0)}`); + if (actualPts.length > 1) g += ``; + // forecast line from split onward + const fcPts = rows.slice(sIdx).map((r, i) => `${x(sIdx + i)},${y(r.Forecast || 0)}`); + if (fcPts.length > 1) g += ``; + // x labels + g += xAxisMonthLabels(rows, x, H); + rows.forEach((r, i) => { + const isFc = i >= sIdx; + g += `${esc(fmtMonth(r.Month))}\n${isFc ? "forecast " + fmtMoneyFull(r.Forecast) : "actual " + fmtMoneyFull(r.Actual)}`; + }); + const legend = legendHtml([ + { label: "Actual", color: PALETTE[0] }, + { label: "Forecast", color: PALETTE[3] }, + ]); + return svgEl(W, H, g, "Cost forecast — actual vs projected") + legend; +} + +/* --------------------------------------------------------------- KPI calc */ + +function deriveKpis(d) { + const s = d.summary?.[0] || {}; + const list = s.List || 0, eff = s.Effective || 0, contracted = s.Contracted || 0, billed = s.Billed || 0; + const savings = list - eff; + const esr = list > 0 ? savings / list : 0; + const negotiated = list - contracted; + const commitment = contracted - eff; + + const tagMap = Object.fromEntries((d.tagged || []).map((r) => [r._t, r.Cost || 0])); + const tagged = tagMap.Tagged || 0, untagged = tagMap.Untagged || 0; + const tagTotal = tagged + untagged; + const untaggedPct = tagTotal > 0 ? untagged / tagTotal : 0; + + const priceMap = Object.fromEntries((d.pricing || []).map((r) => [r.PricingCategory, r.Cost || 0])); + const committed = priceMap.Committed || 0; + const priceTotal = Object.values(priceMap).reduce((a, b) => a + b, 0); + const coverage = priceTotal > 0 ? committed / priceTotal : 0; + + const trend = d.trend || []; + let mom = null, lastMonthVal = null, lastMonthLabel = null; + if (trend.length >= 1) { + const last = trend[trend.length - 1]; + lastMonthVal = last.Effective || 0; + lastMonthLabel = fmtMonth(last.Month); + if (trend.length >= 2) { + const prev = trend[trend.length - 2].Effective || 0; + mom = prev > 0 ? (lastMonthVal - prev) / prev : null; + } + } + + return { + billed, eff, list, contracted, savings, esr, negotiated, commitment, + tagged, untagged, untaggedPct, committed, coverage, + resources: s.Resources || 0, services: s.Services || 0, + subscriptions: s.Subscriptions || 0, regions: s.Regions || 0, + mom, lastMonthVal, lastMonthLabel, + }; +} + +function kpiThreshold(pct, greenMax, amberMax) { + if (pct < greenMax) return "threshold-green"; + if (pct < amberMax) return "threshold-amber"; + return "threshold-red"; +} + +const VALID_TABS = ["overview", "allocation", "rate", "usage", "anomaly", "tokenomics", "ai", "capacity", "monaco"]; + +// "Tool" tabs are experiments that don't follow the KPI dashboard pipeline +// (no preset/filter-driven queries, no response caching) — they render their +// own surface and manage their own state. +const TOOL_TABS = new Set(["monaco"]); + +function switchTab(tabId, opts = {}) { + if (!VALID_TABS.includes(tabId) || (!opts.force && state.loading) || tabId === state.tab) return; + const leavingMonaco = state.tab === "monaco"; + state.tab = tabId; + [...el("tabs").querySelectorAll("button")].forEach((b) => { + const active = b.dataset.tab === tabId; + b.classList.toggle("active", active); + b.setAttribute("aria-selected", active ? "true" : "false"); + }); + revealActiveTab(); + const isTool = TOOL_TABS.has(tabId); + el("preset").hidden = isTool || tabId === "capacity"; + el("refresh").hidden = isTool; + el("app-footer").hidden = isTool; + if (isTool) el("filter-bar").hidden = true; + if (leavingMonaco && tabId !== "monaco") disposeMonacoEditor(); + if (!opts.skipHash) { + const url = new URL(location.href); + url.hash = tabId === "capacity" + ? `tab=capacity&capacity=${state.capacityClass}` + : `tab=${tabId}`; + history.pushState({ tab: tabId }, "", url); + } + if (!opts.skipPublish) void publishCanvasState({ tab: tabId }); + load(); +} + +function tabFromHash() { + const m = /tab=([a-z]+)/.exec(location.hash); + return m && VALID_TABS.includes(m[1]) ? m[1] : null; +} + +function capacityClassFromHash() { + const match = /(?:^|&)capacity=([a-z0-9-]+)/.exec(location.hash.replace(/^#/, "")); + return match && CAPACITY_TABS.some((item) => item.id === match[1]) ? match[1] : null; +} + +export function nextCapacityTabIndex(currentIndex, key, count = CAPACITY_TABS.length) { + if (!Number.isInteger(currentIndex) || currentIndex < 0 || currentIndex >= count || count < 1) return -1; + if (key === "Home") return 0; + if (key === "End") return count - 1; + if (key === "ArrowRight" || key === "ArrowDown") return (currentIndex + 1) % count; + if (key === "ArrowLeft" || key === "ArrowUp") return (currentIndex - 1 + count) % count; + return currentIndex; +} + +function selectCapacityClass(classId, options = {}) { + if (!CAPACITY_TABS.some((item) => item.id === classId) || state.loading) return; + const changed = classId !== state.capacityClass; + state.capacityClass = classId; + if (changed) state.capacitySelections = {}; + if (!options.skipHash) { + history.pushState({ tab: "capacity", capacityClass: classId }, "", `#tab=capacity&capacity=${classId}`); + } + if (!options.skipPublish) { + void publishCanvasState({ capacityClass: classId, capacitySelections: state.capacitySelections }); + } + if (changed || options.force) load(); +} + +function applyCapacitySelection(kind, value) { + if (!["quota", "demand"].includes(kind) || state.loading) return; + const next = { ...state.capacitySelections }; + const selectionName = `${kind}Selection`; + if (!value) { + delete next[selectionName]; + if (kind === "quota") delete next.metricSelection; + } else { + const payload = currentPayload(); + const rows = kind === "demand" + ? payload?.demand?.selectors?.items + : payload?.selectors?.items; + const row = rows?.[Number(value)]; + const selection = capacitySelectionFromRow(kind, state.capacityClass, row); + if (!selection) return; + next[selectionName] = selection; + if (kind === "quota") { + const metric = capacitySelectionFromRow("metric", state.capacityClass, row); + if (metric && Object.values(metric).every(Boolean)) next.metricSelection = metric; + else delete next.metricSelection; + } + } + state.capacitySelections = next; + void publishCanvasState({ capacitySelections: next }); + load(); +} + +function moveCapacityTabFocus(target, key) { + const tabs = [...document.querySelectorAll("[data-capacity-class]")]; + const currentIndex = tabs.indexOf(target); + const nextIndex = nextCapacityTabIndex(currentIndex, key, tabs.length); + tabs.forEach((tab, index) => { + tab.tabIndex = index === nextIndex ? 0 : -1; + }); + tabs[nextIndex]?.focus(); +} + +/* --------------------------------------------------------- triage strip */ + +function buildTriageTile(title, count, cue, tabId) { + const isTeaser = count === null; + const cls = isTeaser ? "is-teaser" : count === 0 ? "threshold-green" : count <= 4 ? "threshold-amber" : "threshold-red"; + const badge = isTeaser ? "Not loaded" : count === 0 ? "Good" : count <= 4 ? "Review" : "Urgent"; + const display = isTeaser ? "—" : count === 0 ? "None" : fmtInt(count); + return ``; +} + +function renderTriageStrip(d) { + // Anomalies: reuse anomaly tab cache when loaded (use same cache key for consistency) + const anomPayload = state.cache["anomaly"]?.[cacheKey()]; + const daily = anomPayload?.data?.daily || []; + const anomCount = anomPayload ? daily.filter((r) => r.Flag !== 0).length : null; + const anomCue = anomCount === null ? "Visit Anomalies & forecast tab to load" + : anomCount === 0 ? "No anomalies detected" + : "Review flagged cost days"; + + // Overspend: months in trend where effective cost rose >20% vs prior month + const trend = d.trend || []; + let overspendCount = 0; + for (let i = 1; i < trend.length; i++) { + const prev = trend[i - 1].Effective || 0; + const curr = trend[i].Effective || 0; + if (prev > 0 && curr > prev * 1.20) overspendCount++; + } + const overspendCue = overspendCount === 0 + ? "Spend within expected range" + : `${overspendCount} month${overspendCount === 1 ? "" : "s"} with >20% spike`; + + // Savings opportunities: underutilized commitments from rate tab cache when loaded + const ratePayload = state.cache["rate"]?.[cacheKey()]; + const byCommitment = ratePayload?.data?.byCommitment || []; + const savingsCount = ratePayload ? byCommitment.filter((r) => (r.Unused || 0) > 0).length : null; + const savingsCue = savingsCount === null ? "Visit Rate optimization tab to load" + : savingsCount === 0 ? "Commitments fully utilized" + : "Underutilized commitments found"; + + return `
+ ${buildTriageTile("Anomalies", anomCount, anomCue, "anomaly")} + ${buildTriageTile("Overspend", overspendCount, overspendCue, "usage")} + ${buildTriageTile("Savings Opportunities", savingsCount, savingsCue, "rate")} +
`; +} + +function isPartialMonth() { + const now = new Date(); + return now.getDate() < new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); +} + +const KPI_TIPS = { + "Untagged cost": "% of spend on resources missing tags. Target: <10% · Review: <25% · Urgent: ≥25%. Tagging enables accurate showback and chargeback.", + "Commitment waste": "% of RI/savings-plan spend on unused capacity. Target: <10% · Review: <20% · Urgent: ≥20%. Idle commitments erode net savings.", + "Effective savings rate": "Negotiated + commitment savings as % of list price. Higher = better. Enterprise customers typically target ≥15–20%.", + "Commitment coverage": "Compute spend covered by RIs or savings plans. Target: ≥60% for steady workloads. Higher coverage → lower effective rate.", + "Compute coverage": "On-demand core-hours offset by commitments. Target: ≥60%. Tracks whether savings plan scope is sufficient.", + "MACC burn rate": "Microsoft Azure Consumption Commitment utilization. Target: ≥90% to avoid forfeiting unused balance at term end.", + "Anomaly days": "Days where daily cost deviated significantly from the expected baseline (STL decomposition). Review flagged dates for unexpected spend.", + "Hourly cost / core": "Compute effective cost per core-hour actually consumed this period — the real, paid-for unit rate.", + "Effective cost / core": "Compute effective cost per core-hour, including unused commitment waste spread across usage — the fully-loaded unit cost if that waste is charged back.", + "Unpredicted variance": "Net effective cost variance between actual spend and the anomaly baseline on flagged days (FinOps KPI: Total Unpredicted Variance of Spend). Positive = spent more than expected.", + "Anomaly detection rate": "Effective cost on anomaly-flagged days as % of total effective spend (FinOps KPI: Anomaly Cost %). The day-count ratio shown alongside is a separate reference stat, not the derivation of this percentage.", + "Last month change": "Month-over-month % change in effective cost vs. the prior month. Watch for spikes or drops that don't match expected seasonality.", + "Forecast next month": "Projected effective cost for next month using time-series decomposition (FinOps KPI: Cost Forecasting). Based on historical trend + seasonality, not a guarantee.", + "Visibility delay": "Median (P50) delay between when cost was incurred and when it appeared in the FinOps hub (FinOps KPI: Cost Visibility Delay). On local/demo data without a live Cost Management connector, a large delay is expected.", + "Tag policy compliance": "% of effective cost on resources with all required tag keys present and non-empty (FinOps KPI: Tagging Policy Compliance).", + "Subscriptions": "Distinct subscriptions (billing accounts) with cost activity in the selected period.", + "Allocated cost": "Effective cost with allocation evidence — a cost center, owner, or ownership tag — the complement of Unallocated cost.", +}; + +function kpiCard(label, value, meta, accent, thresholdClass, tier) { + // Hierarchy tier is now explicitly assigned by each tab's render*() call + // site (via the 6th `tier` argument) rather than an incomplete global + // label allow-list, so every tab consciously designates its own hero + // metric. `accent` is kept for call-site compatibility but unused. + const hierarchyClass = tier === "primary" ? "kpi--primary" : tier === "reference" ? "kpi--reference" : ""; + + // Combine threshold and hierarchy classes + const classArray = [thresholdClass, hierarchyClass].filter(Boolean); + const cls = classArray.length > 0 ? ` ${classArray.join(" ")}` : ""; + + const tip = KPI_TIPS[label]; + const tipHtml = tip ? ` ` : ""; + + return `
+
${esc(label)}${tipHtml}
+
${value}
+
${meta}
+
`; +} + +/* ---------------------------------------------------------------- render */ + +function panelHtml(id, span, title, sub, body) { + const subHtml = sub ? `

${sub}

` : ""; + return `
+

${title}

${subHtml}
+
${body}
+
`; +} + +function openKqlDialog(panelId) { + _kqlPanelId = panelId; + // Prefer the query the server actually executed for this panel; fall back to + // the static map for tabs that don't publish their queries yet. + const served = currentPayload()?.kql?.[PANEL_QUERY[panelId]]; + el("kql-text").value = served || PANEL_KQL[panelId] || ""; + el("kql-error").textContent = ""; + const prev = document.getElementById("kql-result"); + if (prev) prev.remove(); + el("kql-dialog").showModal(); +} + +async function executeKql() { + const kql = el("kql-text").value.trim(); + const errEl = el("kql-error"); + const runBtn = el("kql-run"); + if (!kql) return; + errEl.textContent = ""; + const prev = document.getElementById("kql-result"); + if (prev) prev.remove(); + runBtn.disabled = true; + runBtn.textContent = "Running…"; + try { + const res = await fetch("/api/kql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kql }), + }); + if (!res.ok) { errEl.textContent = `Server error ${res.status}`; return; } + const data = await res.json(); + if (data.error) { + errEl.textContent = data.error; + } else { + const rows = data.rows || []; + if (!rows.length) { + errEl.textContent = "Query returned no rows."; + } else { + el("kql-dialog").close(); + renderKqlResultInPanel(_kqlPanelId, rows); + } + } + } catch (err) { + errEl.textContent = "Request failed: " + err.message; + } finally { + runBtn.disabled = false; + runBtn.textContent = "Run"; + } +} + +function renderKqlResultInPanel(panelId, rows) { + const panelBody = document.querySelector(`[data-panel-id="${panelId}"] .panel-body`); + if (!panelBody) return; + const cols = Object.keys(rows[0]); + const head = cols.map((c) => `${esc(c)}`).join(""); + const body = rows.slice(0, 200).map((r) => + `${cols.map((c) => `${esc(String(r[c] ?? ""))}`).join("")}` + ).join(""); + panelBody.innerHTML = `

${rows.length} rows${rows.length > 200 ? " (showing first 200)" : ""}

${head}${body}
`; +} + +function renderOverview(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet. Ingest cost data, then refresh.

`; + return; + } + const k = deriveKpis(p.data); + + const momClass = k.mom == null ? "" : k.mom > 0 ? "neg" : "pos"; // cost up = bad + const momTxt = k.mom == null ? "—" : `${k.mom > 0 ? "▲" : "▼"} ${fmtPct(Math.abs(k.mom))}`; + + const partialHtml = isPartialMonth() ? ` · partial month` : ""; + const maccRow = p.data.macc?.[0] || { ConsumptionAmount: 0, CommitmentAmount: 0, CommitmentBurnPercent: 0 }; + + const kpis = [ + // primary KPIs first + kpiCard("Untagged cost", fmtPct(k.untaggedPct), + `${fmtMoney(k.untagged)} on untagged resources`, PALETTE[3], + kpiThreshold(k.untaggedPct, 0.10, 0.25), "primary"), + // supporting KPIs + kpiCard("Effective cost", fmtMoney(k.eff), `Billed ${fmtMoney(k.billed)}`, PALETTE[0]), + kpiCard("Total savings", fmtMoney(k.savings), + `${fmtPct(k.esr)} effective savings rate`, PALETTE[1]), + // reference KPIs + kpiCard("Commitment coverage", fmtPct(k.coverage), + `${fmtMoney(k.committed)} of compute spend`, PALETTE[5], undefined, "reference"), + // supporting KPIs + kpiCard("Tracked resources", fmtInt(k.resources), + `${fmtInt(k.services)} services · ${fmtInt(k.subscriptions)} subs · ${fmtInt(k.regions)} regions`, PALETTE[2]), + kpiCard("Latest month", k.lastMonthVal == null ? "—" : fmtMoney(k.lastMonthVal), + k.mom == null ? (k.lastMonthLabel ? `${esc(k.lastMonthLabel)}${partialHtml}` : (isPartialMonth() ? `partial month` : "")) : `${momTxt} vs prior · ${esc(k.lastMonthLabel)}${partialHtml}`, PALETTE[4]), + // macc-consumption-vs-commitment — MACC burn rate. Demote to reference + // tier when unconfigured (N/A) so an empty card doesn't take full + // primary-grid visual weight. + kpiCard("MACC burn rate", + maccRow.CommitmentAmount > 0 ? fmtPct(maccRow.CommitmentBurnPercent / 100) : "N/A", + maccRow.CommitmentAmount > 0 + ? `${fmtMoney(maccRow.ConsumptionAmount)} of ${fmtMoney(maccRow.CommitmentAmount)} committed` + : "No Microsoft Azure Consumption Commitment data", + PALETTE[7], undefined, maccRow.CommitmentAmount > 0 ? undefined : "reference"), + ].join(""); + + const d = p.data; + const html = ` + ${renderTriageStrip(d)} +
${kpis}
+ +

Understand usage & cost

FinOps Framework
+
+ ${panelHtml("overview-trend", 12, "Monthly cost trend", "Billed vs effective cost by month — executive run-rate view.", lineChart(d.trend))} + ${panelHtml("overview-top-services", 6, "Top services by cost", "Effective cost by Azure service.", hbar(d.topServices, "ServiceName", "Cost", { label: "Top services by cost" }))} + ${panelHtml("overview-service-category", 6, "Cost by service category", "Where spend concentrates across categories.", hbar(d.serviceCategory, "ServiceCategory", "Cost", { label: "Cost by service category" }))} +
+ +

Optimize usage & cost

FinOps Framework
+
+ ${panelHtml("overview-top-rgs", 6, "Top resource groups", "Largest cost owners for allocation & accountability.", hbar(d.topResourceGroups, "x_ResourceGroupName", "Cost", { label: "Top resource groups" }))} + ${panelHtml("overview-top-regions", 6, "Cost by region", "Regional spend for placement & sustainability review.", hbar(d.topRegions, "RegionId", "Cost", { label: "Cost by region" }))} +
+ +

Quantify business value

FinOps Framework
+
+ ${panelHtml("overview-rate-coverage", 4, "Rate coverage", "Committed vs on-demand (standard) effective cost.", donut([ + { label: "Committed", value: k.committed, color: PALETTE[1] }, + { label: "On-demand", value: Math.max(0, k.eff - k.committed), color: PALETTE[0] }, + ], { centerBig: fmtPct(k.coverage), centerSmall: "covered", label: "Rate coverage" }))} + ${panelHtml("overview-savings", 4, "Savings breakdown", "List → effective, by discount type.", savingsTable(k))} + ${panelHtml("overview-cost-allocation", 4, "Cost allocation", "Tagged vs untagged effective cost.", donut([ + { label: "Tagged", value: k.tagged, color: PALETTE[1] }, + { label: "Untagged", value: k.untagged, color: UNKNOWN_COLOR, isUnknown: true }, + ], { centerBig: fmtPct(1 - k.untaggedPct), centerSmall: "tagged", label: "Cost allocation" }))} +
+ `; + content.innerHTML = html; +} + +function savingsTable(k) { + return costBreakdownTable([ + { label: "List cost", val: k.list, accent: "var(--muted)" }, + { label: "Negotiated savings", val: k.negotiated, accent: PALETTE[8] }, + { label: "Commitment savings", val: k.commitment, accent: PALETTE[1] }, + { label: "Effective cost", val: k.eff, accent: PALETTE[0] }, + ], "Effective savings rate", fmtPct(k.esr)); +} + +/* ----------------------------------------------------- tokenomics render */ + +function deriveTokenKpis(d) { + const s = d.summary?.[0] || {}; + const tokens = s.Tokens || 0, eff = s.Effective || 0; + const cloud = d.totalCloud?.[0]?.Effective || 0; + const dir = Object.fromEntries((d.direction || []).map((r) => [r.Direction, r])); + const inTok = dir["Input"]?.Tokens || 0; + const cachedTok = dir["Cached input"]?.Tokens || 0; + const cachedShare = inTok + cachedTok > 0 ? cachedTok / (inTok + cachedTok) : 0; + return { + tokens, eff, cloud, + blendedPer1K: tokens > 0 ? eff / tokens * 1000 : 0, + cachedShare, + aiShare: cloud > 0 ? eff / cloud : 0, + models: s.Models || 0, + resources: s.Resources || 0, + }; +} + +function renderTokenomics(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No AI token data

+

No Azure OpenAI token meters were found in the Hub database for this period.

+

Tokenomics tracks meters where x_SkuMeterSubcategory contains “OpenAI” and the SKU is billed in tokens. Ingest Azure OpenAI usage, then refresh.

`; + return; + } + const d = p.data; + const k = deriveTokenKpis(d); + + const dirColors = { "Input": PALETTE[0], "Cached input": PALETTE[1], "Output": PALETTE[3], "Other": PALETTE[6] }; + const dirSlices = (d.direction || []).map((r) => ({ + label: r.Direction, value: r.Tokens || 0, cost: r.Cost || 0, color: dirColors[r.Direction] || PALETTE[6], + })); + + const kpis = [ + // reference KPIs first (no primaries in this tab) + kpiCard("Total tokens", fmtTokens(k.tokens), `across ${fmtInt(k.models)} model families`, PALETTE[0], undefined, "reference"), + // supporting KPIs + kpiCard("AI token cost", fmtMoney(k.eff), `${fmtPct(k.aiShare, 2)} of all cloud cost`, PALETTE[2], undefined, "primary"), + kpiCard("Blended rate", fmtPerM(k.blendedPer1K), `per 1M tokens (effective)`, PALETTE[5]), + kpiCard("Cached input", fmtPct(k.cachedShare), + `${fmtPct(k.cachedShare)} of input tokens cached`, PALETTE[1]), + kpiCard("AI resources", fmtInt(k.resources), `Azure OpenAI deployments`, PALETTE[4]), + kpiCard("Models in use", fmtInt(k.models), `distinct model families`, PALETTE[8]), + ].join(""); + + content.innerHTML = ` +
${kpis}
+ +

AI token economics

Token Consumption Metrics KPI
+
+ ${panelHtml("token-trend", 12, "Token volume & AI cost trend", "Monthly token consumption (bars) and effective AI cost (line).", tokenTrendChart(d.trend))} + ${panelHtml("token-by-model", 6, "AI cost by model", "Effective cost per model family.", + hbar((d.models || []).map((m) => ({ Model: m.Model, Cost: m.Cost })), "Model", "Cost", { label: "AI cost by model" }))} + ${panelHtml("token-direction", 6, "Token direction mix", "Input vs cached input vs output — by token volume.", + donut(dirSlices, { + centerBig: fmtTokens(k.tokens), centerSmall: "tokens", + valueFmt: (s) => `${fmtTokens(s.value)} · ${fmtMoney(s.cost)}`, + label: "Token direction mix", + }))} +
+ +

Model efficiency

Rate & usage optimization
+
+ ${panelHtml("token-model-table", 12, "Cost per 1M tokens by model", "Unit economics for model selection — sorted by effective cost.", tokenModelTable(d.models, k.eff))} +
+ +

AI cost allocation

Showback & chargeback
+
+ ${panelHtml("token-by-app", 12, "AI cost by application", "Azure OpenAI effective cost and token volume by application, team, environment, and cost center.", aiByAppTable(d.byApplication))} +
+ `; +} + +function tokenModelTable(models, totalCost) { + const rows = (models || []).filter((m) => (m.Tokens || 0) > 0); + if (rows.length === 0) return `

No token data in range.

`; + const maxPer1K = Math.max(...rows.map((m) => m.CostPer1K || 0), 1e-9); + const body = rows.map((m, i) => { + const color = PALETTE[i % PALETTE.length]; + const share = totalCost > 0 ? (m.Cost || 0) / totalCost : 0; + const barW = Math.max(2, ((m.CostPer1K || 0) / maxPer1K) * 90); + return ` + ${swatchHtml(color)}${esc(m.Model)} + ${fmtTokens(m.Tokens)} + ${fmtMoneyFull(m.Cost)} + ${fmtPerM(m.CostPer1K)} + ${fmtPct(share)} + `; + }).join(""); + return ` + + ${body} +
ModelTokensEffective cost$ / 1M tokens% of AI cost
`; +} + +function aiByAppTable(rows) { + const data = (rows || []).filter((r) => (r.EffectiveCost || 0) > 0); + if (data.length === 0) return `

No tagged AI cost data. Tag Azure OpenAI resources with application, team, or environment tags.

`; + const totalCost = data.reduce((a, r) => a + (r.EffectiveCost || 0), 0); + const untaggedCount = data.filter((r) => !r.Application).length; + const callout = untaggedCount === data.length + ? `
100% of AI cost (${fmtMoney(totalCost)}) is untagged — no application-level chargeback is currently possible. Tag Azure OpenAI resources with an application tag to enable it.
` + : ""; + return callout + ` + + ${data.map((r, i) => { + const share = totalCost > 0 ? (r.EffectiveCost || 0) / totalCost : 0; + const isUnknown = !r.Application; + const color = PALETTE[i % PALETTE.length]; + return ` + + + + + + + + + `; + }).join("")} +
ApplicationTeamEnvironmentCost centerTokensEffective cost$/1M tokens% of AI
${swatchHtml(color, isUnknown)}${esc(r.Application || "(untagged)")}${esc(r.Team || "—")}${esc(r.Environment || "—")}${esc(r.CostCenter || "—")}${fmtTokens(r.TokenCount)}${fmtMoney(r.EffectiveCost)}${fmtPerM(r.CostPer1KTokens)}${fmtPct(share)}
`; +} + +/* --------------------------------------------- AI & emerging workloads render */ + +// Middle-ellipsis a cell value and expose the full string on hover, so long +// meter and series names shorten predictably instead of overflowing the +// `white-space: nowrap` table cells. +function nameCell(value, n) { + // `??` alone lets an empty string through, which renders as a blank cell and + // reads as a rendering failure rather than as absent data. + const s = String(value ?? "").trim() || "—"; + const short = trunc(s, n); + return short === s ? esc(s) : `${esc(short)}`; +} + +// Wrap a table that can exceed its panel width. The first column stays pinned +// while the numeric columns scroll, so a row never loses its label. +function wideTable(html) { + return `
${html}
`; +} + +function aiCapabilityTable(rows, estate) { + const money = moneyColumn(rows, "Cost"); + return wideTable(tableHtml([ + { label: "Capability", align: "left", get: (r) => + `${swatchHtml(aiColor(r.Capability))}${nameCell(r.Capability, 26)}` }, + { label: "Services", get: (r) => fmtInt(r.Services) }, + { label: "Cost", get: (r) => money(r.Cost) }, + { label: "Share", get: (r) => estate > 0 ? fmtShare(r.Cost / estate, 1) : "—" }, + ], rows, "No AI/ML estate cost in range.")); +} + +function aiModelBenchTable(rows) { + const money = moneyColumn(rows, "Cost"); + const rate = rateColumn(rows, "Cpmt"); + return wideTable(tableHtml([ + { label: "Model family", align: "left", get: (r) => nameCell(r.Family, 26) }, + { label: "Tokens", get: (r) => fmtTokens(r.Tokens) }, + { label: "Cost", get: (r) => money(r.Cost) }, + { label: "$ / 1M tokens", get: (r) => rate(r.Cpmt) }, + ], rows, "No foundation model token meters in range.")); +} + +function aiDirectionTable(rows) { + const rate = rateColumn(rows, "Cpmt"); + const total = (rows || []).reduce((s, r) => s + (r.Tokens || 0), 0); + return wideTable(tableHtml([ + { label: "Direction", align: "left", get: (r) => nameCell(r.Direction, 26) }, + { label: "Tokens", get: (r) => fmtTokens(r.Tokens) }, + { label: "Share", get: (r) => total > 0 ? fmtShare(r.Tokens / total, 1) : "—" }, + { label: "$ / 1M tokens", get: (r) => rate(r.Cpmt) }, + ], rows, "No foundation model token meters in range.")); +} + +export function deriveAiKpis(d, lastClosedMonth) { + const months = d.monthly || []; + const sum = (key) => months.reduce((s, r) => s + (r[key] || 0), 0); + const cloud = sum("Cloud"), estate = sum("Estate"), mlGpu = sum("MlGpu"); + const tokens = sum("Tokens"), tokenCost = sum("TokenCost"); + + // Anchor month-over-month to the last *closed* month reported by the server. + // The newest month in the window is normally a partial ingestion month, and + // comparing it against a full month reports a collapse that isn't real. + const closedIdx = lastClosedMonth ? months.findIndex((r) => r.Month === lastClosedMonth) : -1; + const closed = closedIdx >= 0 ? months[closedIdx] : null; + const prior = closedIdx > 0 ? months[closedIdx - 1] : null; + const mom = closed && prior && prior.Estate > 0 ? (closed.Estate - prior.Estate) / prior.Estate : null; + + const a = (d.allocation || [])[0] || {}; + const allocTotal = a.Total || 0; + const appCoverage = allocTotal > 0 ? (a.App || 0) / allocTotal : null; + + const posture = (d.posture || [])[0] || {}; + + return { + cloud, estate, mlGpu, tokens, tokenCost, + estateShare: cloud > 0 ? estate / cloud : 0, + mlGpuShare: estate > 0 ? mlGpu / estate : 0, + cpmt: tokens > 0 ? (tokenCost / tokens) * 1000000 : null, + mom, closedMonth: lastClosedMonth, hasClosedMonth: !!closed, + partialMonth: months.length > 0 && months[months.length - 1].Month !== lastClosedMonth + ? months[months.length - 1].Month : null, + alloc: a, allocTotal, appCoverage, + committedShare: posture.Total > 0 ? (posture.Committed || 0) / posture.Total : null, + recommendations: ((d.recommendations || [])[0] || {}).Count ?? 0, + transactions: ((d.transactions || [])[0] || {}).Count ?? 0, + }; +} + +function aiAllocationTable(k) { + const rows = [ + { Dimension: "Application tag", Covered: k.alloc.App || 0 }, + { Dimension: "Owner / team tag", Covered: k.alloc.Owner || 0 }, + { Dimension: "Cost center", Covered: k.alloc.CostCenter || 0 }, + { Dimension: "Resource group", Covered: k.alloc.ResourceGroup || 0 }, + ]; + if (k.allocTotal <= 0) return `

No AI/ML estate cost in range.

`; + const money = moneyColumn(rows, "Covered"); + return wideTable(tableHtml([ + { label: "Dimension", align: "left", get: (r) => esc(r.Dimension) }, + { label: "Covered cost", get: (r) => money(r.Covered) }, + { label: "Coverage", get: (r) => { + const pct = r.Covered / k.allocTotal; + const cls = pct >= 0.85 ? "pos" : pct >= 0.65 ? "warn" : "neg"; + return `${fmtShare(pct)}`; + } }, + ], rows)); +} + +function aiPostureTable(k) { + // Counts are descriptive: a zero means no AI-scoped evidence was ingested, + // which is a different statement from "no opportunity exists". + const rows = [ + { + Signal: "Commitment coverage", + Value: k.committedShare == null ? "—" : fmtPct(k.committedShare), + Note: k.committedShare ? "AI/ML estate cost on a commitment discount" : "No AI/ML spend is on a commitment discount", + }, + { + Signal: "AI-scoped rate recommendations", + Value: fmtInt(k.recommendations), + Note: k.recommendations > 0 ? "Open recommendations touching AI/ML resource types" : "None ingested for AI/ML resource types", + }, + { + Signal: "AI-scoped commitment transactions", + Value: fmtInt(k.transactions), + Note: k.transactions > 0 ? "Purchase or refund events matching AI/GPU descriptions" : "None ingested matching AI/GPU descriptions", + }, + ]; + return wideTable(tableHtml([ + { label: "Signal", align: "left", get: (r) => esc(r.Signal) }, + { label: "Value", get: (r) => r.Value }, + { label: "Evidence", align: "left", get: (r) => `${esc(r.Note)}` }, + ], rows)); +} + +function aiDriversTable(rows, k) { + const money = moneyColumn(rows, "Prev", "Cost"); + const delta = moneyColumn(rows, "Change"); + // Below half a cent the change is a rounding artefact, not a movement: format + // it as a flat zero so it can't render as a signed "-$0.00 (-0.0%)" and can't + // pick up a directional colour. + const EPS = 0.005; + return wideTable(tableHtml([ + { label: "Service", align: "left", get: (r) => nameCell(r.Service, 26) }, + { label: "Meter", align: "left", get: (r) => nameCell(r.Meter, 26) }, + { label: "Prior month", get: (r) => money(r.Prev) }, + { label: k.closedMonth ? fmtMonth(k.closedMonth) : "Latest month", get: (r) => money(r.Cost) }, + { label: "Change", get: (r) => { + const chg = Math.abs(r.Change || 0) < EPS ? 0 : r.Change; + const cls = chg > 0 ? "neg" : chg < 0 ? "pos" : "muted"; + if (chg === 0) return `no change`; + // A zero baseline has no percentage; say so rather than leaving the + // cell ragged against the rows that carry one. + const pct = r.Prev > 0 + ? ` (${chg > 0 ? "+" : ""}${fmtShare(chg / r.Prev, 1)})` + : ` (new)`; + return `${chg > 0 ? "+" : ""}${delta(chg)}${pct}`; + } }, + ], rows, "No month-over-month movement in range.")); +} + +function renderAi(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No AI or emerging workload data

+

No AI, machine learning, or GPU-accelerated spend was found in the Hub database for this period.

+

This view scopes to the AI and Machine Learning service category, Azure AI Search, Azure Databricks, and GPU VM series (NC/ND/NV/NG). Ingest cost data covering those workloads, then refresh.

`; + return; + } + const d = p.data; + const k = deriveAiKpis(d, p.lastClosedMonth); + + const momTxt = k.mom == null ? null : `${k.mom > 0 ? "+" : ""}${fmtPct(k.mom, 1)}`; + const momCls = k.mom == null ? "muted" : k.mom > 0 ? "neg" : "pos"; + const estateMeta = momTxt + ? `${momTxt} vs prior · ${esc(fmtMonth(k.closedMonth))}` + : `${fmtPct(k.estateShare, 1)} of all cloud cost`; + + const covCls = k.appCoverage == null ? undefined + : k.appCoverage >= 0.85 ? "threshold-green" : k.appCoverage >= 0.65 ? "threshold-amber" : "threshold-red"; + + const cpmtTrend = (d.monthly || []) + .filter((r) => (r.Tokens || 0) > 0) + .map((r) => ({ Month: r.Month, Cpmt: (r.TokenCost / r.Tokens) * 1000000 })); + + const kpis = [ + kpiCard("AI/ML estate spend", fmtMoney(k.estate), estateMeta, PALETTE[2], undefined, "primary"), + kpiCard("ML & GPU compute", fmtMoney(k.mlGpu), `${fmtPct(k.mlGpuShare, 1)} of AI/ML estate`, PALETTE[9]), + kpiCard("Token volume", fmtTokens(k.tokens), `${fmtMoney(k.tokenCost)} in token meters`, PALETTE[0]), + kpiCard("Cost per 1M tokens", k.cpmt == null ? "—" : fmtRate(k.cpmt), + k.cpmt == null ? "No token meters in range" : "Blended across all model families", PALETTE[5]), + kpiCard("AI allocation coverage", k.appCoverage == null ? "—" : fmtPct(k.appCoverage), + k.appCoverage == null ? "No AI/ML estate cost in range" : "Carrying an application tag", + PALETTE[3], covCls), + kpiCard("AI share of cloud", fmtPct(k.estateShare, 1), `${fmtMoney(k.estate)} of ${fmtMoney(k.cloud)}`, PALETTE[1], undefined, "reference"), + ].join(""); + + // One money scale per detail table, derived from that table's own maximum. + const mlGpuMoney = moneyColumn(d.mlGpu, "Cost"); + const mlUnitMoney = moneyColumn(d.mlUnit, "Cost"); + const mlUnitVmRate = rateColumn(d.mlUnit, "PerVmHour"); + const mlUnitCoreRate = rateColumn(d.mlUnit, "Per1KCoreHours"); + const searchMoney = moneyColumn(d.search, "Cost"); + const cognitiveMoney = moneyColumn(d.cognitive, "Cost"); + + const partialNote = k.partialMonth + ? ` Month-over-month figures compare ${esc(fmtMonth(k.closedMonth))} against the month before it; ${esc(fmtMonth(k.partialMonth))} is still ingesting and is excluded from those comparisons.` + : ""; + + content.innerHTML = ` +
${kpis}
+ +

This view scopes to the AI and Machine Learning service category plus Azure AI Search, Azure Databricks, and GPU VM series (NC/ND/NV/NG). GPU capacity bought outside those services — or AI work running on general-purpose compute — will not appear here.${partialNote}

+ +

AI/ML estate

Workload composition
+
+ ${panelHtml("ai-capability-trend", 12, "AI spend by capability over time", "Monthly effective cost split across AI capability groups.", aiCapabilityChart(d.capabilityTrend))} + ${panelHtml("ai-capability", 6, "Estate composition", "Effective cost and distinct services per capability.", aiCapabilityTable(d.capability, k.estate))} + ${panelHtml("ai-by-service", 6, "Estate spend by service", "Top billing services in the AI/ML estate.", + hbar(d.byService, "Service", "Cost", { filterDim: "ServiceName", nameW: 210, color: PALETTE[2], label: "AI/ML estate spend by service" }))} +
+ +

Token & model economics

Unit economics
+
+ ${panelHtml("ai-token-demand", 6, "Token demand", "Monthly token volume across all foundation model meters.", + monthAreaChart(d.monthly, { valueKey: "Tokens", color: PALETTE[2], valFmt: fmtTokens, label: "Monthly token volume" }))} + ${panelHtml("ai-cpmt-trend", 6, "Cost per 1M tokens", "Blended effective rate — the direction of travel matters more than the level.", + monthAreaChart(cpmtTrend, { valueKey: "Cpmt", color: PALETTE[5], valFmt: axisRate, tipFmt: fmtRate, label: "Blended cost per 1M tokens" }))} + ${panelHtml("ai-model-bench", 6, "Model family benchmark", "Cost per 1M tokens by model family — the input to model selection.", aiModelBenchTable(d.modelBench))} + ${panelHtml("ai-direction", 6, "Token direction mix", "Input, cached input, output, and embedding meters.", aiDirectionTable(d.direction))} +
+ +

Workload detail

Compute, retrieval & applied AI
+
+ ${panelHtml("ai-ml-gpu", 12, "ML platform & GPU compute", "Components behind machine learning and accelerated compute spend.", + wideTable(tableHtml([ + { label: "Component", align: "left", get: (r) => nameCell(r.Component, 28) }, + { label: "Unit", align: "left", get: (r) => nameCell(r.Unit, 16) }, + { label: "Quantity", get: (r) => fmtQty(r.Quantity) }, + { label: "Cost", get: (r) => mlGpuMoney(r.Cost) }, + ], d.mlGpu)))} + ${panelHtml("ai-search", 12, "AI Search / retrieval", "Azure AI Search meters supporting retrieval-augmented generation.", + wideTable(tableHtml([ + { label: "Meter", align: "left", get: (r) => nameCell(r.Meter, 28) }, + { label: "Unit", align: "left", get: (r) => nameCell(r.Unit, 16) }, + { label: "Quantity", get: (r) => fmtQty(r.Quantity) }, + { label: "Cost", get: (r) => searchMoney(r.Cost) }, + ], d.search, "No Azure AI Search meters in range.")))} + ${panelHtml("ai-ml-unit", 6, "ML compute unit economics", "Effective rate per VM-hour and per 1K core-hours by VM series.", + wideTable(tableHtml([ + { label: "Series", align: "left", get: (r) => nameCell(r.Series, 24) }, + { label: "VM hours", get: (r) => fmtQty(r.VmHours) }, + { label: "$ / VM-hour", get: (r) => mlUnitVmRate(r.PerVmHour) }, + { label: "$ / 1K core-hours", get: (r) => mlUnitCoreRate(r.Per1KCoreHours) }, + { label: "Cost", get: (r) => mlUnitMoney(r.Cost) }, + ], d.mlUnit, "No ML virtual machine meters in range.")))} + ${panelHtml("ai-cognitive", 6, "Cognitive & applied AI", "Speech, vision, language, and video services, excluding token meters.", + wideTable(tableHtml([ + { label: "Service", align: "left", get: (r) => nameCell(r.Service, 30) }, + { label: "Quantity", get: (r) => fmtQty(r.Units) }, + { label: "Cost", get: (r) => cognitiveMoney(r.Cost) }, + ], d.cognitive, "No cognitive or applied AI meters in range.")))} +
+ +

Allocation & posture

Accountability & rate optimization
+
+ ${panelHtml("ai-allocation", 6, "Allocation coverage", "Share of AI/ML estate cost carrying each accountability dimension.", aiAllocationTable(k))} + ${panelHtml("ai-by-owner", 6, "Estate spend by owner", "Owner or team tag, falling back to cost center then resource group. Tag values are folded case-insensitively.", + hbar(d.byOwner, "Owner", "Cost", { filterDim: null, nameW: 210, color: PALETTE[2], label: "AI/ML estate spend by owner" }))} + ${panelHtml("ai-posture", 12, "Commitment & rate posture", "Whether AI/ML spend is on a commitment, and what AI-scoped rate evidence exists.", aiPostureTable(k))} + ${panelHtml("ai-drivers", 12, "Top movers", `Largest AI/ML meters, ${k.closedMonth ? `${esc(fmtMonth(k.closedMonth))} against the month before it` : "latest month against the month before it"}.`, aiDriversTable(d.drivers, k))} +
+ `; +} + +/* --------------------------------------------- anomalies & forecast render */ + +function renderAnomaly(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet.

`; + return; + } + const d = p.data; + const daily = d.daily || []; + const anomDays = daily.filter((r) => r.Flag !== 0); + const totalCost = daily.reduce((a, r) => a + (r.Cost || 0), 0); + const anomCost = anomDays.reduce((a, r) => a + (r.Cost || 0), 0); + const variance = Math.abs(anomDays.reduce((a, r) => a + ((r.Cost || 0) - (r.Baseline || 0)), 0)); + const rate = totalCost > 0 ? anomCost / totalCost : 0; + + const fc = d.forecast || []; + const dataMaxMonth = (p.window?.dataMax || "").slice(0, 7); + const nextFc = fc.find((r) => r.Month > dataMaxMonth); + + const mc = (d.monthlyChange || []).filter((r) => isFinite(r.EffChangePct)); + // last complete month (skip the partial dataMax month for the headline KPI) + const completeMc = mc.filter((r) => r.Month < dataMaxMonth); + const lastMc = completeMc[completeMc.length - 1] || mc[mc.length - 1]; + + const fr = d.freshness?.[0] || {}; + const p50Days = fr.P50 != null ? fr.P50 / 24 : null; + + const mcClass = lastMc == null ? "" : lastMc.EffChangePct > 0 ? "neg" : "pos"; // cost up = bad + const mcArrow = lastMc == null ? "" : lastMc.EffChangePct > 0 ? "▲" : "▼"; + const mcValue = lastMc == null ? "—" : `${mcArrow} ${fmtPct(Math.abs(lastMc.EffChangePct) / 100)}`; + + const kpis = [ + // reference KPIs first (no primaries in this tab) + kpiCard("Anomaly days", fmtInt(anomDays.length), + `${fmtMoney(anomCost)} on flagged days`, undefined, undefined, "reference"), + // supporting KPIs + kpiCard("Anomaly detection rate", fmtPct(rate, 2), + `% of effective spend on flagged days · ${fmtInt(anomDays.length)} of ${fmtInt(daily.length)} days flagged`, undefined), + kpiCard("Unpredicted variance", fmtMoney(variance), + `net spend vs baseline on anomaly days`, undefined), + kpiCard("Last month change", mcValue, + lastMc ? `effective cost · ${esc(fmtMonth(lastMc.Month))}` : "", undefined), + kpiCard("Forecast next month", nextFc ? fmtMoney(nextFc.Forecast) : "—", + nextFc ? `projected · ${esc(fmtMonth(nextFc.Month))}` : "", undefined), + kpiCard("Visibility delay", p50Days != null ? `${p50Days.toFixed(0)}d` : "—", + `median ingestion lag (P50)`, undefined), + ].join(""); + + const triageCallout = anomDays.length > 0 + ? `
${fmtInt(anomDays.length)} anomal${anomDays.length === 1 ? "y day" : "y days"} detected — ${fmtMoney(anomCost)} in flagged spend. Review the chart below.
` + : ""; + + content.innerHTML = ` + ${triageCallout} +
${kpis}
+ +

Cost anomalies

Anomaly management capability
+
+ ${panelHtml("anomaly-daily", 12, "Daily cost & detected anomalies", "Daily effective cost vs the expected baseline (STL decomposition); markers flag spikes & drops.", anomalyChart(daily))} +
+ +

Trend & forecast

Forecasting · Data freshness
+
+ ${panelHtml("anomaly-mom", 6, "Month-over-month change", "Effective cost % change vs prior month (red = increase).", momBars(mc))} + ${panelHtml("anomaly-forecast", 6, "Cost forecast", "Monthly effective cost, actual vs forecast (next 3 months).", forecastChart(fc, dataMaxMonth))} +
+ `; +} + +/* ----------------------------------------------- usage & unit economics render */ + +function renderUsage(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet.

`; + return; + } + const d = p.data; + const c = d.compute?.[0] || {}; + const s = d.storage?.[0] || {}; + const coreHours = c.CoreHours || 0; + const hourlyPerCore = coreHours > 0 ? c.ComputeEff / coreHours : 0; + const effPerCore = coreHours > 0 ? (c.ComputeEff + (c.UnusedCommit || 0)) / coreHours : 0; + const gbMonths = s.GBMonths || 0; + const perGB = gbMonths > 0 ? s.Cost / gbMonths : 0; + const total = d.total?.[0]?.Total || 0; + + const kpis = [ + kpiCard("Hourly cost / core", `$${hourlyPerCore.toFixed(3)}`, + `per consumed vCPU-hour`, PALETTE[0], undefined, "primary"), + kpiCard("Effective cost / core", `$${effPerCore.toFixed(3)}`, + `incl. unused commitment`, PALETTE[2], undefined, "reference"), + kpiCard("Compute core-hours", fmtTokens(coreHours), + `${fmtMoney(c.ComputeEff)} VM usage`, PALETTE[1]), + kpiCard("Storage rate", `$${(perGB * 1024).toFixed(3)}`, + `per TB-month (effective)`, PALETTE[5]), + kpiCard("Storage volume", `${fmtTokens(gbMonths)}`, + `GB-months stored`, PALETTE[8]), + kpiCard("Storage cost", fmtMoney(s.Cost), + `effective storage spend`, PALETTE[3]), + ].join(""); + + const typeRows = (d.topResourceTypes || []).map((r) => ({ + type: r.ResourceType, count: r.Resources || 0, cost: r.Cost || 0, + pct: total > 0 ? (r.Cost || 0) / total : 0, + })); + const typeTable = tableHtml([ + { label: "Resource type", align: "left", get: (r, i) => `${swatchHtml(PALETTE[i % PALETTE.length])}${esc(r.type)}` }, + { label: "Resources", get: (r) => fmtInt(r.count) }, + { label: "Effective cost", get: (r) => fmtMoneyFull(r.cost) }, + { label: "% of total", get: (r) => fmtPct(r.pct) }, + ], typeRows); + + const tierColors = { "Frequent": PALETTE[1], "Infrequent": PALETTE[5], "Unclassified": UNKNOWN_COLOR }; + const tierSlices = (d.storageTiers || []).map((r) => ({ label: r.Tier, value: r.Cost || 0, color: tierColors[r.Tier] || PALETTE[6], isUnknown: r.Tier === "Unclassified" })); + const freqShare = (() => { + const t = tierSlices.reduce((a, x) => a + x.value, 0); + const f = (d.storageTiers || []).find((r) => r.Tier === "Frequent"); + return t > 0 ? (f?.Cost || 0) / t : 0; + })(); + + content.innerHTML = ` +
${kpis}
+ +

Usage & unit economics

Usage optimization · Unit economics
+
+ ${panelHtml("usage-top-types", 12, "Top resource types by cost", "Resource count and effective spend per resource type.", typeTable)} + ${panelHtml("usage-per-core-series", 6, "Compute cost per core by VM series", "Effective cost per vCPU-hour — highlights expensive (e.g. GPU) cores.", + hbar(d.perCoreSeries, "x_SkuMeterSubcategory", "PerCore", { valFmt: (v) => `$${v.toFixed(3)}`, label: "Compute cost per core by VM series" }))} + ${panelHtml("usage-storage-tiers", 6, `Storage tier distribution`, `Effective storage cost by access tier (${fmtPct(freqShare)} classified frequent).`, + donut(tierSlices, { centerBig: fmtMoney(s.Cost), centerSmall: "storage", label: "Storage tier distribution" }))} +
+ `; +} + +function renderRate(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet.

`; + return; + } + const d = p.data; + const s = d.savings?.[0] || {}; + const cm = d.commitment?.[0] || {}; + const cc = d.computeCoverage?.[0] || {}; + const esr = s.List > 0 ? s.Total / s.List : 0; + const cmTotal = cm.Total || 0; + const util = cmTotal > 0 ? (cmTotal - (cm.Unused || 0)) / cmTotal : 0; + const waste = cmTotal > 0 ? (cm.Unused || 0) / cmTotal : 0; + const coverage = cc.Contracted > 0 ? cc.Committed / cc.Contracted : 0; + const coreTotal = (d.coreHours || []).reduce((a, r) => a + (r.CoreHours || 0), 0); + const committedCore = (d.coreHours || []).filter((r) => r.t !== "On Demand").reduce((a, r) => a + (r.CoreHours || 0), 0); + const coreShare = coreTotal > 0 ? committedCore / coreTotal : 0; + // Single source of truth for "Commitment waste" coloring: derive the meta + // text color from the same threshold the card border uses, instead of a + // separately hardcoded 0.1 cutoff that could silently drift out of sync. + const wasteThreshold = kpiThreshold(waste, 0.10, 0.20); + const wasteMetaCls = wasteThreshold === "threshold-red" ? "neg" : wasteThreshold === "threshold-amber" ? "warn" : "pos"; + + const kpis = [ + // primary KPIs first + kpiCard("Effective savings rate", fmtPct(esr), + `${fmtMoney(s.Total)} total savings · vs. list price`, PALETTE[1], undefined, "primary"), + kpiCard("Commitment waste", fmtPct(waste), + `${fmtMoney(cm.Unused)} unused · of commitment spend`, PALETTE[3], + wasteThreshold, "primary"), + // supporting KPIs + kpiCard("Total savings", fmtMoney(s.Total), + `of ${fmtMoney(s.List)} list cost`, PALETTE[2]), + (() => { + const cusRow = (d.commitmentUtilScore || []).find((r) => r.CommitmentDiscountName === '(Grand Total)'); + const cusScore = cusRow ? cusRow.Score / 100 : util; + return kpiCard("Commitment utilization", fmtPct(cusScore), + cusRow + ? `${fmtMoney(cusRow.Amount)} utilized of ${fmtMoney(cusRow.Potential)} potential` + : `${fmtMoney(cmTotal - (cm.Unused || 0))} of ${fmtMoney(cmTotal)} used`, + PALETTE[0]); + })(), + // reference KPIs + kpiCard("Compute coverage", fmtPct(coverage), + `compute spend on commitments`, PALETTE[5], undefined, "reference"), + // supporting KPIs + kpiCard("Committed core-hours", fmtPct(coreShare), + `RI + savings plan vs on-demand`, PALETTE[8], undefined, "reference"), + ].join(""); + + const savingsBreak = costBreakdownTable([ + { label: "List cost (excl. commitment purchases)", val: s.List, accent: "var(--muted)" }, + { label: "Negotiated savings", val: s.Negotiated, accent: PALETTE[8] }, + { label: "Commitment savings", val: s.Commitment, accent: PALETTE[1] }, + { label: "Effective cost", val: s.Effective, accent: PALETTE[0] }, + ], "Effective savings rate", fmtPct(esr)); + + const coreColors = { "On Demand": PALETTE[0], "Reservation": PALETTE[1], "Savings Plan": PALETTE[4] }; + const coreSlices = (d.coreHours || []).map((r) => ({ label: r.t, value: r.CoreHours || 0, color: coreColors[r.t] || PALETTE[6] })); + + const underutilCount = (d.byCommitment || []).filter((r) => (r.Unused || 0) > 0).length; + const rateCallout = underutilCount > 0 + ? `
${fmtInt(underutilCount)} underutilized commitment${underutilCount === 1 ? "" : "s"} found — ${fmtMoney(cm.Unused)} in unused spend. See the commitments panel below.
` + : ""; + + content.innerHTML = ` + ${rateCallout} +
${kpis}
+ +

Rate optimization

Rate optimization capability
+
+ ${panelHtml("rate-savings", 6, "Savings breakdown", "List → effective cost by discount type (effective savings rate).", savingsBreak)} + ${panelHtml("rate-commit-util", 6, "Commitment utilization", "Used vs unused commitment effective cost.", + donut([ + { label: "Used", value: cmTotal - (cm.Unused || 0), color: PALETTE[1] }, + { label: "Unused (waste)", value: cm.Unused || 0, color: PALETTE[3] }, + ], { centerBig: fmtPct(util), centerSmall: "utilized", label: "Commitment utilization" }))} + ${panelHtml("rate-core-hours", 6, "Core-hour coverage", "Consumed core-hours by commitment type.", + donut(coreSlices, { + centerBig: fmtPct(coreShare), centerSmall: "committed", + valueFmt: (s) => `${fmtTokens(s.value)} core-hrs`, + label: "Core-hour coverage", + }))} + ${panelHtml("rate-underutil", 6, "Underutilized commitments", "Reservations & plans with the most unused cost.", + hbar(d.byCommitment, "CommitmentDiscountName", "Unused", { label: "Underutilized commitments" }))} +
+ +

Commitment transactions

Rate optimization · Commitment purchasing
+
+ ${panelHtml("rate-commit-score", 6, "Commitment utilization score", "Per-commitment utilization (used vs potential) from the formal CUS KPI.", commitUtilTable(d.commitmentUtilScore))} + ${panelHtml("rate-top-txns", 6, "Top commitment transactions", "Largest RI and savings plan purchases by billed cost. Effective cost is $0 by design — amortization credits the cost to the months the commitment is consumed, not the purchase month.", topCommitTxnTable(d.topCommitmentTxns))} +
+ `; +} + +function commitUtilTable(rows) { + const data = (rows || []).filter((r) => r.CommitmentDiscountName !== '(Grand Total)' && (r.Potential || 0) > 0); + if (data.length === 0) return `

No commitment data in range.

`; + return ` + + ${data.map((r) => { + const score = r.Score || 0; + const cls = score < 70 ? "neg" : score < 90 ? "warn" : "pos"; + const barW = Math.max(2, (score / 100) * 90); + return ` + + + + + + `; + }).join("")} +
CommitmentTypeScoreUtilizedPotential
${esc(r.CommitmentDiscountName)}${esc(r.CommitmentDiscountType || r.CommitmentDiscountCategory || "")}${fmtPct(score / 100)}${fmtMoney(r.Amount)}${fmtMoney(r.Potential)}
`; +} + +function topCommitTxnTable(rows) { + const data = rows || []; + if (data.length === 0) return `

No commitment transactions in range.

`; + return ` + + ${data.map((r) => ` + + + + + `).join("")} +
CommitmentTypeBilled costEffective cost
${esc(r.CommitmentDiscountName || "(unknown)")}${esc(r.CommitmentDiscountType || "")}${fmtMoney(r.BilledCost)}${fmtMoney(r.EffectiveCost)}
`; +} + +/* ----------------------------------------------------- allocation render */ + +function renderAllocation(p) { + const content = el("content"); + if (!p) return; + if (p.error) return renderError(p); + if (p.empty) { + content.innerHTML = `

No cost data

The Hub database has no rows yet.

`; + return; + } + const d = p.data; + const c = d.core?.[0] || {}; + const total = c.Total || 0; + const aai = total > 0 ? c.Attributed / total : 0; + const untaggedPct = total > 0 ? c.Untagged / total : 0; + const unallocPct = total > 0 ? (total - c.Attributed) / total : 0; + const compliancePct = total > 0 ? c.Compliant / total : 0; + + const kpis = [ + // primary KPIs first + kpiCard("Untagged cost", fmtPct(untaggedPct), + `${fmtMoney(c.Untagged)} with no tags`, PALETTE[3], + kpiThreshold(untaggedPct, 0.10, 0.25), "primary"), + // supporting KPIs + kpiCard("Allocation accuracy", fmtPct(aai), + `directly attributed effective cost`, PALETTE[1]), + kpiCard("Unallocated cost", fmtPct(unallocPct), + `${fmtMoney(total - c.Attributed)} lacks allocation evidence`, PALETTE[4]), + kpiCard("Tag policy compliance", fmtPct(compliancePct), + `keys: CostCenter · env · org`, PALETTE[5]), + kpiCard("Subscriptions", fmtInt(c.Subs), + `billing scopes in range`, PALETTE[0]), + kpiCard("Allocated cost", fmtMoney(c.Attributed), + `of ${fmtMoney(total)} total`, PALETTE[2]), + ].join(""); + + const hierRows = (d.hierarchy || []).map((r) => ({ + org: r.Org || "—", project: r.Project || "—", env: r.Env || "—", cost: r.Cost || 0, + pct: total > 0 ? (r.Cost || 0) / total : 0, + })); + const hierTable = tableHtml([ + { + label: "Org", align: "left", get: (r, i) => { + const isUnknown = r.org === "—" && r.project === "—" && r.env === "—"; + return `${swatchHtml(PALETTE[i % PALETTE.length], isUnknown)}${esc(r.org)}`; + }, + }, + { label: "Project", align: "left", get: (r) => esc(r.project) }, + { label: "Environment", align: "left", get: (r) => esc(r.env) }, + { label: "Effective cost", get: (r) => fmtMoneyFull(r.cost) }, + { label: "% of total", get: (r) => fmtPct(r.pct) }, + ], hierRows); + + // Flag case-variant duplicate tag keys (e.g. "CostCenter" vs "costcenter") + // so the governance issue is called out, not hidden by treating them as + // separate keys. + const tagKeyRows = d.tagKeys || []; + const lowerCounts = {}; + tagKeyRows.forEach((r) => { const lk = String(r.k).toLowerCase(); lowerCounts[lk] = (lowerCounts[lk] || 0) + 1; }); + const dupKeys = tagKeyRows.filter((r) => lowerCounts[String(r.k).toLowerCase()] > 1).map((r) => r.k); + const tagKeyNote = dupKeys.length > 0 + ? ` Note: ${dupKeys.map((k) => `${esc(k)}`).join(" vs ")} are case-variant duplicates of the same governance key — likely inconsistent tagging, not distinct keys.` + : ""; + + content.innerHTML = ` +
${kpis}
+ +

Cost allocation

Allocation capability
+
+ ${panelHtml("alloc-hierarchy", 8, "Cost by financial hierarchy", "Org → project → environment (from resource tags), with share of total.", hierTable)} + ${panelHtml("alloc-tagging", 4, "Tagging coverage", "Tagged vs untagged effective cost.", + donut([ + { label: "Tagged", value: total - c.Untagged, color: PALETTE[1] }, + { label: "Untagged", value: c.Untagged, color: UNKNOWN_COLOR, isUnknown: true }, + ], { centerBig: fmtPct(1 - untaggedPct), centerSmall: "tagged", label: "Tagging coverage" }))} + ${panelHtml("alloc-tag-keys", 6, "Cost by tag key", `Effective cost touched by each governance tag.${tagKeyNote}`, hbar(d.tagKeys, "k", "Cost", { filterDim: null, label: "Cost by tag key" }))} + ${panelHtml("alloc-by-subscription", 6, "Cost by subscription", "Spend per billing scope for showback.", hbar(d.bySubscription, "SubAccountName", "Cost", { label: "Cost by subscription" }))} +
+ `; +} + +const CAPACITY_ACTIONS = Object.freeze({ + "app-service": "Validate region access and SKU availability separately before requesting an exact SKU quota increase.", + "azure-ai": "Validate model availability, deployment scope, and actual capacity separately from the provider quota row.", + compute: "Check both total regional and applicable VM-family vCPU quota, then validate SKU, zone, and physical capacity separately.", + "azure-sql": "Use the exact SQL metric and service workflow. Do not treat countdown or negative-limit rows as generic utilization.", + storage: "Validate ingestion and expected subscription-region coverage before drawing a Storage quota conclusion.", + "capacity-reservations": "Inspect reservation quantity, SKU, zones, sharing, associations, and utilization in Azure; inventory count is not reserved capacity.", + "premium-ssd-v2": "Inspect disk zone, attachment, IOPS, throughput, and service quota separately; observed GiB is inventory, not quota.", +}); + +const CAPACITY_STATE_LABELS = Object.freeze({ + healthy: "Healthy", + watch: "Watch", + action: "Action", + exhausted: "Exhausted", + "no-entitlement": "No entitlement", + inventory: "Observed inventory", + unclassified: "Unknown or unclassified", + stale: "Stale", + invalid: "Invalid or conflict", +}); + +export function capacitySelectionFromRow(kind, classId, row) { + if (!row || typeof row !== "object") return null; + if (kind === "quota") { + if (classId === "capacity-reservations" || classId === "premium-ssd-v2") { + return { resourceId: String(row.ResourceId || "") }; + } + return { + subAccountId: String(row.SubAccountId || ""), + location: String(row.location || ""), + resourceName: String(row.ResourceName || ""), + unit: String(row.unit || ""), + sourceVersion: String(row.x_SourceVersion || ""), + }; + } + if (kind === "metric") { + return { + resourceName: String(row.ResourceName || ""), + unit: String(row.unit || ""), + sourceVersion: String(row.x_SourceVersion || ""), + }; + } + const selection = { + meterCategory: String(row.x_SkuMeterCategory || ""), + meterSubcategory: String(row.x_SkuMeterSubcategory || ""), + meter: String(row.SkuMeter || ""), + priceId: String(row.SkuPriceId || ""), + currency: String(row.BillingCurrency || ""), + }; + if (classId === "premium-ssd-v2") selection.resourceId = String(row.InventoryResourceId || ""); + else selection.unit = String(row.ConsumedUnit || ""); + if (classId === "capacity-reservations") { + selection.capacityReservationId = String(row.CapacityReservationId || ""); + selection.capacityReservationStatus = String(row.CapacityReservationStatus || ""); + } + return selection; +} + +function sameCapacitySelection(left, right) { + return JSON.stringify(left || null) === JSON.stringify(right || null); +} + +function capacityNavigationHtml() { + return ``; +} + +function capacityPanel(title, subtitle, body) { + return `
+

${esc(title)}

${subtitle ? `

${esc(subtitle)}

` : ""}
+
${body}
+
`; +} + +function capacityStateToken(semantic = {}) { + const stateName = semantic.state || "unclassified"; + const label = CAPACITY_STATE_LABELS[stateName] || stateName; + return `${esc(label)}`; +} + +function capacityHomeTable(classes) { + const rows = classes || []; + return `
+ + ${rows.map((item) => { + const summary = item.summary || {}; + const observed = Number(summary.Observations || 0) > 0; + return ` + + + + + + + `; + }).join("")} +
Evidence classTypeObservationsResourcesSnapshot daysLast seen
${esc(item.evidenceLabel)}
${esc(item.evidenceClass === "inventory" ? "Inventory" : "Provider metric")}${observed ? fmtInt(summary.Observations) : "No evidence"}${observed ? fmtInt(summary.Resources) : "—"}${observed ? fmtInt(summary.DistinctDays) : "—"}${summary.LatestObservation ? esc(fmtRelativeTime(new Date(summary.LatestObservation))) : "—"}
`; +} + +function capacitySelectorHtml(kind, classId, items, currentSelection) { + const isDemand = kind === "demand"; + const label = isDemand ? "Billed demand series" : "Quota or inventory series"; + const options = (items || []).map((row, index) => { + const selection = capacitySelectionFromRow(kind, classId, row); + const selected = sameCapacitySelection(selection, currentSelection); + const display = isDemand + ? classId === "premium-ssd-v2" + ? `${row.DiskName || row.InventoryResourceId} · ${row.SkuMeter || "No matched cost"} · ${row.BillingCurrency || "—"}` + : `${row.SkuMeter || row.x_SkuMeterSubcategory || "Unknown meter"} · ${row.ConsumedUnit || "—"} · ${row.BillingCurrency || "—"}` + : `${row.ResourceName || row.displayName || "Unknown"} · ${row.SubAccountId || "—"} · ${row.location || "—"}`; + const disabled = Object.values(selection || {}).some((value) => !value); + return ``; + }).join(""); + return ``; +} + +function formatCapacityValue(value) { + const number = Number(value); + if (!Number.isFinite(number)) return "—"; + return number.toLocaleString("en-US", { maximumFractionDigits: 2 }); +} + +function capacityCurrentTable(payload) { + const inventory = payload.contract?.evidenceClass === "inventory"; + const rows = payload.table?.rows || []; + const columns = [ + { label: "Subscription", align: "left", get: (row) => esc(trunc(row.SubAccountId || "—", 28)) }, + { label: "Region", align: "left", get: (row) => esc(row.location || "—") }, + { label: inventory ? "Resource" : "Metric", align: "left", get: (row) => `${esc(row.displayName || row.ResourceName || "—")}` }, + { label: inventory && payload.classId === "premium-ssd-v2" ? "Size GiB" : "Current", get: (row) => inventory && payload.classId === "capacity-reservations" ? "Observed" : formatCapacityValue(row.currentValue) }, + { label: "Limit", get: (row) => inventory ? "Not applicable" : formatCapacityValue(row.limit) }, + { label: "Unit", get: (row) => inventory && payload.classId === "premium-ssd-v2" ? "GiB" : esc(row.unit || "—") }, + { label: "Evidence state", align: "left", get: (row) => `${capacityStateToken(row.semantic)}${esc(row.semantic?.evidenceLabel || "")}` }, + { label: "Ingested", get: (row) => esc(fmtRelativeTime(new Date(row.x_IngestionTime))) }, + ]; + return `
${tableHtml(columns, rows, payload.contract?.emptyLabel)}
`; +} + +export function capacityHeatmapCell(row, classId) { + if (classId === "capacity-reservations") { + return { value: Number(row.ObservedObjects || 0), text: `${fmtInt(row.ObservedObjects)} groups`, state: "inventory" }; + } + if (classId === "premium-ssd-v2") { + return { value: Number(row.ObservedGiB || 0), text: `${formatCapacityValue(row.ObservedGiB)} GiB`, state: "inventory" }; + } + const semantic = row.semantic || {}; + return { + value: semantic.utilizationPercent, + text: Number.isFinite(semantic.utilizationPercent) ? `${semantic.utilizationPercent.toFixed(1)}%` : (CAPACITY_STATE_LABELS[semantic.state] || "No evidence"), + state: semantic.state || "unclassified", + }; +} + +function capacityHeatmap(payload) { + const heatmap = payload.heatmap || {}; + if (heatmap.status === "heatmap-disabled") { + return `
Heatmap disabled. More than ${fmtInt(heatmap.limit)} observed cells matched. Refine the filters; no partial matrix was rendered.
`; + } + if (heatmap.status === "no-selection") { + return `
Select one exact quota metric to enable the subscription-by-region matrix.
`; + } + const rows = heatmap.rows || []; + if (!rows.length) return `
No heatmap evidence is available for this selection.
`; + if (payload.contract?.evidenceClass !== "inventory" && !rows.some((row) => row.semantic?.capability === "enabled")) { + return `
Heatmap unavailable. The selected metric is descriptive-only and cannot receive quota-health color.
`; + } + const subscriptions = [...new Set(rows.map((row) => row.SubAccountId || "Unknown subscription"))].sort(); + const regions = [...new Set(rows.map((row) => row.location || "Unknown region"))].sort(); + const cells = new Map(rows.map((row) => [`${row.SubAccountId || "Unknown subscription"}|${row.location || "Unknown region"}`, row])); + return `
+ + ${regions.map((region) => ``).join("")} + ${subscriptions.map((subscription) => ` + + ${regions.map((region) => { + const row = cells.get(`${subscription}|${region}`); + if (!row) return ``; + const cell = capacityHeatmapCell(row, payload.classId); + return ``; + }).join("")} + `).join("")} +
Subscription by region. Every colored cell includes the same value and state in text.
Subscription${esc(region)}
${esc(trunc(subscription, 20))}No evidence${esc(cell.text)}${esc(CAPACITY_STATE_LABELS[cell.state] || cell.state)}
`; +} + +function capacityHistory(payload) { + const history = payload.history || {}; + if (history.status === "no-selection") return `
Select one exact source row to view its observed history.
`; + if (history.status === "disabled") return `
History is disabled. ${esc(history.reasonCode || "")}
`; + if (history.mode === "current-only") { + return `
Collecting ${payload.contract?.evidenceClass === "inventory" ? "inventory" : "quota"} history — 1 day available. Trend, growth, forecast, runway, and breach dates remain disabled.
`; + } + return tableHtml([ + { label: "UTC day", align: "left", get: (row) => esc(String(row.Day || "").slice(0, 10)) }, + { label: "Current", get: (row) => formatCapacityValue(row.currentValue) }, + { label: "Limit", get: (row) => payload.contract?.evidenceClass === "inventory" ? "Not applicable" : formatCapacityValue(row.limit) }, + { label: "Unit", get: (row) => payload.classId === "premium-ssd-v2" ? "GiB" : esc(row.unit || "—") }, + { label: "Ingested", get: (row) => esc(String(row.x_IngestionTime || "")) }, + ], history.points || [], "No history is available for this exact source key."); +} + +function capacityDemandHistory(payload) { + const series = payload.series || {}; + if (series.status === "disabled") { + return `
Billed-demand series disabled. ${esc(series.reasonCode || "")}
`; + } + if (series.status === "no-selection") { + return `
Select one exact meter, unit, price, and currency series. Different meters and currencies are never combined.
`; + } + const isDisk = payload.classId === "premium-ssd-v2"; + return tableHtml([ + { label: "UTC day", align: "left", get: (row) => esc(String(row.Day || "").slice(0, 10)) }, + ...(isDisk ? [] : [{ label: "Billed quantity", get: (row) => formatCapacityValue(row.BilledQuantity) }]), + { label: "Unit", get: (row) => isDisk ? "Not classified" : esc(row.ConsumedUnit || series.unit || "—") }, + { label: "Effective cost", get: (row) => `${formatCapacityValue(row.EffectiveCost)} ${esc(row.BillingCurrency || "")}` }, + { label: "Rows", get: (row) => fmtInt(row.Rows) }, + ], series.points || [], "No billed-demand evidence matched this exact series."); +} + +function capacityReconciliation(payload) { + const rows = payload.reconciliation?.rows || []; + return tableHtml([ + { label: "Capacity reservation group", align: "left", get: (row) => `${esc(row.GroupName || trunc(row.GroupResourceId, 36))}` }, + { label: "Match", align: "left", get: (row) => esc(row.ReconciliationState || "unknown") }, + { label: "Used hours", get: (row) => formatCapacityValue(row.UsedHours) }, + { label: "Unused hours", get: (row) => formatCapacityValue(row.UnusedHours) }, + { label: "Reservations", get: (row) => fmtInt(row.ReservationCount) }, + { label: "Linked resources", get: (row) => fmtInt(row.LinkedResources) }, + { label: "Currency", get: (row) => esc(row.BillingCurrency || "—") }, + ], rows, "No capacity reservation inventory or linked billing evidence is available."); +} + +function renderCapacity(payload) { + const content = el("content"); + if (!payload) return; + if (payload.error) return renderError(payload); + const nav = capacityNavigationHtml(); + if (payload.classId === "home") { + content.innerHTML = `${nav}
+
+
Capacity evidence

Capacity workspace

+

Quota entitlement, billed demand, inventory, physical supply, and pricing commitments are separate evidence paths.

+
+ ${capacityPanel("Available evidence", "Seven independent classes; no combined health score or ranking.", capacityHomeTable(payload.classes))} +
`; + return; + } + + const rows = payload.table?.rows || []; + const statusCounts = rows.reduce((counts, row) => { + const key = row.semantic?.state || "unclassified"; + counts[key] = (counts[key] || 0) + 1; + return counts; + }, {}); + const enabledCount = rows.filter((row) => row.semantic?.capability === "enabled").length; + const coverage = payload.coverage || {}; + const noEvidence = coverage.state === "no-evidence" + ? `
No evidence — collection outcome unknown. ${esc(payload.contract?.emptyLabel || "")}
` + : ""; + const schemaWarnings = [payload.schema?.quota, payload.schema?.costs] + .filter((schema) => schema && !schema.available) + .map((schema) => `${schema.source}: ${schema.missingFields.join(", ")}`); + const schemaNotice = schemaWarnings.length + ? `
Source fields unavailable. ${esc(schemaWarnings.join(" · "))}
` + : ""; + const quotaSelectors = payload.selectors?.items || []; + const demandSelectors = payload.demand?.selectors?.items || []; + const kpis = [ + kpiCard("Observations", fmtInt(coverage.observations), `${fmtInt(coverage.resources)} current resource keys`, undefined, undefined, "reference"), + kpiCard("Snapshot days", fmtInt(coverage.distinctDays), coverage.distinctDays < 2 ? "No trend can be inferred" : "Compatible history is evaluated per exact key"), + kpiCard("Latest ingestion", coverage.lastObservation ? esc(fmtRelativeTime(new Date(coverage.lastObservation))) : "—", "ADX arrival time, not provider observation time"), + kpiCard("Enabled", fmtInt(enabledCount), "Rows with approved semantics"), + kpiCard("Unclassified", fmtInt(statusCounts.unclassified), "Raw evidence retained; registry review required"), + kpiCard("Stale", fmtInt(statusCounts.stale), "Older than 48 hours; arithmetic disabled"), + ].join(""); + + content.innerHTML = `${nav}
+
+
${esc(payload.contract?.evidenceClass || "evidence")}

${esc(payload.contract?.title || payload.classId)}

+

${esc(payload.capability?.evidenceLabel || payload.contract?.evidenceLabel || "")}

+
+ ${noEvidence}${schemaNotice} +
Next action: ${esc(CAPACITY_ACTIONS[payload.classId] || "Review the source evidence before taking action.")}
+
${kpis}
+
+ ${capacitySelectorHtml("quota", payload.classId, quotaSelectors, state.capacitySelections.quotaSelection)} + ${capacitySelectorHtml("demand", payload.classId, demandSelectors, state.capacitySelections.demandSelection)} +
+
+ ${capacityPanel("Current evidence", `${payload.table?.rowLimit || 250}-row bound${payload.table?.truncated ? " reached" : ""}. Raw rows remain visible when calculations are disabled.`, capacityCurrentTable(payload))} + ${capacityPanel("Observed history", "Ingestion time is ADX arrival time. Missing days are not inferred.", capacityHistory(payload))} + ${capacityPanel("Subscription × region", "Quota color is available only for exact enabled metrics. Inventory uses neutral density.", capacityHeatmap(payload))} + ${capacityPanel("Parallel billed demand", payload.demand?.capability?.evidenceLabel || "Financial and usage evidence remains separate from quota.", capacityDemandHistory(payload))} + ${payload.classId === "capacity-reservations" + ? capacityPanel("Inventory and billing reconciliation", "Used and Unused are accounting statuses, not reserved-capacity utilization.", capacityReconciliation(payload)) + : ""} +
+
`; +} + +function renderError(p) { + el("content").innerHTML = `
+

Can’t reach the FinOps hub

+

The dashboard queried ${esc(p.clusterUri || "")} (database ${esc(p.database || "Hub")}) but the request failed.

+
+

Start the Kusto emulator, then run:

+
+ Initialize-FinOpsHubLocal + +
+

Then refresh this dashboard.

+
+
+ Show error detail +
${esc(p.error)}
+
+
`; +} + +/* ------------------------------------------------------- experimental tabs */ + +const KUSTO_MONACO_VERSION = "15.0.0"; + +let _monacoEditor = null; +let _monacoModel = null; +let _monacoApi = null; + +/** + * @kusto/monaco-kusto's jsdelivr `+esm` bundle imports its own pinned copy of + * "monaco-editor" by exact CDN URL (version + subpath baked in at jsdelivr's + * build time). Since browser ES module caching is keyed by exact URL string, + * importing monaco-editor via any other URL -- even the "same" version -- + * yields a second, unrelated monaco instance, and `monaco.languages.kusto` + * never registers on the one our own code holds. So instead of guessing a + * monaco-editor version/path, discover the exact specifier kusto-monaco uses + * and import through that. + */ +async function resolveSharedMonacoEditorUrl() { + const kustoBundleUrl = `https://cdn.jsdelivr.net/npm/@kusto/monaco-kusto@${KUSTO_MONACO_VERSION}/+esm`; + const kustoBundleSrc = await fetch(kustoBundleUrl).then((r) => r.text()); + const match = /from"(\/npm\/monaco-editor@[^"]+)"/.exec(kustoBundleSrc); + if (!match) throw new Error("could not locate monaco-editor import in @kusto/monaco-kusto bundle"); + return { kustoBundleUrl, monacoEditorUrl: `https://cdn.jsdelivr.net${match[1]}` }; +} + +/** + * Chromium refuses to construct a Worker (classic or module) from a + * cross-origin script URL at all, even with permissive CORS headers -- so + * `new Worker("https://cdn.jsdelivr.net/...")` throws a SecurityError + * unconditionally. Work around this by fetching the script ourselves and + * handing the browser a same-origin `blob:` URL instead. jsdelivr's `+esm` + * bundles reference their own dependencies via root-relative specifiers + * (e.g. `"/npm/..."`), which don't resolve against a `blob:` base, so those + * are rewritten to fully-qualified jsdelivr URLs first. + */ +async function blobWorkerUrl(scriptUrl) { + let src = await fetch(scriptUrl).then((r) => r.text()); + src = src.replace(/(["'])\/npm\//g, "$1https://cdn.jsdelivr.net/npm/"); + return URL.createObjectURL(new Blob([src], { type: "text/javascript" })); +} + +function disposeMonacoEditor() { + // editor.dispose() only tears down the view widget -- the text model is a + // separate disposable and leaks (along with its worker) if not disposed + // too, which matters here since renderMonacoTab() re-creates both every + // time the tab is (re-)entered, e.g. after a cluster switch. + if (_monacoEditor) { + try { _monacoEditor.dispose(); } catch { /* best-effort cleanup */ } + _monacoEditor = null; + } + if (_monacoModel) { + try { _monacoModel.dispose(); } catch { /* best-effort cleanup */ } + _monacoModel = null; + } +} + +async function renderMonacoTab() { + const content = el("content"); + content.innerHTML = ` +
+
+ Experimental — A KQL query editor with real autocomplete via + @kusto/monaco-kusto, loaded from a CDN with no build step. Suggestions are + grounded in this Hub database's live schema. + Docs ↗ +
+
+ + Loading query editor… +
+
+
+
+ `; + + const statusEl = el("monaco-status"); + const hostEl = el("monaco-host"); + + try { + if (!_monacoApi) { + statusEl.textContent = "Loading query editor + KQL language support from CDN…"; + const { kustoBundleUrl, monacoEditorUrl } = await resolveSharedMonacoEditorUrl(); + const monacoBase = monacoEditorUrl.replace(/\/esm\/.*$/, ""); + // Import monaco-editor via the exact URL @kusto/monaco-kusto itself + // imports it from, so both packages share one module instance + // (required for monaco.languages.kusto to register on our copy). + _monacoApi = await import(monacoEditorUrl); + const [genericWorkerUrl, kustoWorkerUrl] = await Promise.all([ + blobWorkerUrl(`${monacoBase}/esm/vs/editor/editor.worker.js/+esm`), + blobWorkerUrl(`https://cdn.jsdelivr.net/npm/@kusto/monaco-kusto@${KUSTO_MONACO_VERSION}/release/esm/kusto.worker.js/+esm`), + ]); + self.MonacoEnvironment = { + getWorker(_moduleId, label) { + return new Worker(label === "kusto" ? kustoWorkerUrl : genericWorkerUrl, { type: "module" }); + }, + }; + await import(kustoBundleUrl); + } + const monaco = _monacoApi; + + disposeMonacoEditor(); + // Seed from whatever was last saved server-side (survives page reloads, + // including the host restarting this extension's server process), not a + // hardcoded sample -- see saveQueryState() below for how it gets there. + const initialQuery = (window.__cfg && window.__cfg.lastQuery) || "Costs\n| take 20"; + const model = monaco.editor.createModel(initialQuery, "kusto"); + _monacoModel = model; + _monacoEditor = monaco.editor.create(hostEl, { + model, + theme: document.documentElement.getAttribute("data-color-mode") === "dark" ? "vs-dark" : "vs", + automaticLayout: true, + minimap: { enabled: false }, + fontSize: 13, + }); + _monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => runMonacoQuery()); + _monacoEditor.onDidChangeModelContent(() => scheduleQueryStateSave(_monacoEditor.getValue())); + + statusEl.textContent = "Fetching database schema…"; + try { + const cfg = window.__cfg || {}; + const schemaRes = await fetch("/api/schema").then((r) => r.json()); + const kustoLang = monaco.languages?.kusto; + if (schemaRes.schema && kustoLang?.getKustoWorker) { + const workerAccessor = await kustoLang.getKustoWorker(); + const worker = await workerAccessor(model.uri); + await worker.setSchemaFromShowSchema(schemaRes.schema, cfg.clusterUri || "", cfg.database || "Hub"); + statusEl.textContent = `Ready — schema loaded from ${esc(cfg.database || "Hub")}.`; + } else { + statusEl.textContent = schemaRes.error + ? `Ready — schema unavailable: ${esc(schemaRes.error)}` + : "Ready — KQL language service didn't register (autocomplete may be limited)."; + } + } catch (schemaErr) { + statusEl.textContent = `Ready — schema load failed: ${esc(schemaErr.message || String(schemaErr))}`; + } + } catch (err) { + // Graceful fallback: never leave the tab blank if the CDN load fails + // (e.g. cross-origin module workers unsupported in this webview). + const initialQuery = (window.__cfg && window.__cfg.lastQuery) || "Costs\n| take 20"; + hostEl.innerHTML = ``; + el("monaco-fallback").addEventListener("input", (e) => scheduleQueryStateSave(e.target.value)); + statusEl.textContent = `Query editor failed to load here (${esc(err.message || String(err))}) — using a plain text editor instead.`; + } + el("monaco-run").addEventListener("click", () => runMonacoQuery()); +} + +// Debounced autosave of the query editor's text to the server (see +// /api/query-state in extension.mjs), so an in-progress, unrun query +// survives a page reload -- e.g. the host restarting this extension's server +// process, which reassigns its ephemeral port and forces a fresh load. +let _queryStateSaveTimer = null; +function scheduleQueryStateSave(query) { + clearTimeout(_queryStateSaveTimer); + _queryStateSaveTimer = setTimeout(() => { + fetch("/api/query-state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }).catch(() => { /* best-effort; the next successful save will catch up */ }); + }, 600); +} + +async function runMonacoQuery() { + const runBtn = el("monaco-run"); + const statusEl = el("monaco-status"); + const resultEl = el("monaco-result"); + const kql = _monacoEditor ? _monacoEditor.getValue().trim() : (el("monaco-fallback")?.value || "").trim(); + if (!kql) return; + runBtn.disabled = true; + const prevStatus = statusEl.textContent; + statusEl.textContent = "Running…"; + try { + const res = await fetch("/api/kql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kql }), + }); + const data = await res.json(); + if (data.error) { + resultEl.innerHTML = `

${esc(data.error)}

`; + } else { + const rows = data.rows || []; + if (!rows.length) { + resultEl.innerHTML = `

Query returned no rows.

`; + } else { + const cols = Object.keys(rows[0]); + const head = cols.map((c) => `${esc(c)}`).join(""); + const body = rows.slice(0, 200).map((r) => + `${cols.map((c) => `${esc(String(r[c] ?? ""))}`).join("")}` + ).join(""); + const note = rows.length > 200 ? ` (showing first 200)` : ""; + resultEl.innerHTML = `

${rows.length} rows${note}

${head}${body}
`; + } + } + } catch (err) { + resultEl.innerHTML = `

Request failed: ${esc(err.message)}

`; + } finally { + runBtn.disabled = false; + statusEl.textContent = prevStatus; + } +} + +/* ----------------------------------------------------------------- driver */ + +function currentPayload() { + return state.cache[state.tab]?.[cacheKey()]; +} + +function render() { + const p = currentPayload(); + if (!p) return; + try { + if (p.error) renderError(p); + else if (state.tab === "tokenomics") renderTokenomics(p); + else if (state.tab === "ai") renderAi(p); + else if (state.tab === "allocation") renderAllocation(p); + else if (state.tab === "rate") renderRate(p); + else if (state.tab === "usage") renderUsage(p); + else if (state.tab === "anomaly") renderAnomaly(p); + else if (state.tab === "capacity") renderCapacity(p); + else renderOverview(p); + } catch (err) { + console.error("[ftk-dashboard] render error:", err); + renderError({ error: `Render error in ${state.tab}: ${err.message}` }); + } +} + +async function load() { + const tab = state.tab; + if (TOOL_TABS.has(tab)) { + el("source-line").textContent = "Experimental tab — not part of the FinOps KPI pipeline."; + el("footer-meta").textContent = ""; + renderMonacoTab(); + return; + } + const key = cacheKey(); + if (state.cache[tab]?.[key]) { updateChrome(); render(); return; } + + // Cancel any in-flight request for a superseded tab/preset + if (_loadAbort) _loadAbort.abort(); + _loadAbort = new AbortController(); + const { signal } = _loadAbort; + + state.cache[tab] = state.cache[tab] || {}; + state.loading = true; + setRefreshSpinning(true); + const contentEl = el("content"); + contentEl.setAttribute("aria-busy", "true"); + contentEl.innerHTML = ` +
+ ${'
'.repeat(6)} +
+
+
+ `; + try { + const res = await fetch("/api/view", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: tab, + preset: state.preset, + filters: state.filters, + ...(tab === "capacity" + ? { capacityClass: state.capacityClass, capacitySelections: state.capacitySelections } + : {}), + }), + signal, + }); + state.cache[tab][key] = await res.json(); + } catch (err) { + if (err.name === "AbortError") return; // superseded by a newer load(); discard silently + console.error("[ftk-dashboard] fetch failed:", err); + state.cache[tab][key] = { error: "Could not load data. Check the FinOps hub connection and authentication." }; + } finally { + state.loading = false; + setRefreshSpinning(false); + el("content")?.setAttribute("aria-busy", "false"); + } + updateChrome(); + render(); +} + +function setRefreshSpinning(on) { + const b = el("refresh"); + if (b) b.innerHTML = on ? ` Refresh` : `↻ Refresh`; +} + +function renderDiagnosticRail() { + const railEl = el("diagnostic-rail"); + if (!railEl) return; + const { rows, health, refreshedAt, dataset } = queryState; + const relTime = fmtRelativeTime(refreshedAt); + const absTime = refreshedAt ? refreshedAt.toLocaleString() : ""; + const rowTxt = `${fmtInt(rows)} rows`; + const healthLabel = health === "error" ? "● error" : health === "warn" ? "● warn" : "● ok"; + railEl.innerHTML = + `${esc(dataset)}` + + `` + + `${rowTxt}` + + `` + + `${healthLabel}` + + `` + + `${esc(relTime)}`; +} + +function updateChrome() { + const p = currentPayload(); + const w = p && p.window; + if (w && w.dataMin) { + el("source-line").innerHTML = + `Hub database · ${esc(window.__cfg?.clusterUri || "localhost:8082")}`; + queryState.dataset = `Hub database · ${fmtDayRange(w.dataMin, w.dataMax)}`; + queryState.rows = w.rows || 0; + queryState.health = queryState.rows === 0 ? "warn" : "ok"; + queryState.refreshedAt = p.generatedAt ? new Date(p.generatedAt) : new Date(); + el("footer-meta").textContent = `window ${w.start} → ${w.end}`; + renderDiagnosticRail(); + } else if (p && p.error) { + el("source-line").textContent = "Connection failed — see panel below."; + el("footer-meta").textContent = ""; + queryState.rows = 0; + queryState.health = "error"; + queryState.refreshedAt = new Date(); + queryState.dataset = "Hub database"; + renderDiagnosticRail(); + } else if (p && state.tab === "capacity") { + const observations = p.classId === "home" + ? (p.classes || []).reduce((sum, item) => sum + Number(item.summary?.Observations || 0), 0) + : Number(p.coverage?.observations || 0); + el("source-line").innerHTML = + `Hub capacity evidence · ${esc(window.__cfg?.clusterUri || "localhost:8082")}`; + el("footer-meta").textContent = p.classId === "home" ? "seven evidence classes" : p.contract?.title || p.classId; + queryState.rows = observations; + queryState.health = p.error ? "error" : observations > 0 ? "ok" : "warn"; + queryState.refreshedAt = p.generatedAt ? new Date(p.generatedAt) : new Date(); + queryState.dataset = p.classId === "home" ? "Capacity evidence index" : p.contract?.title || "Capacity evidence"; + renderDiagnosticRail(); + } +} + +/** Open the connection-settings dialog, prefilled from the current config. */ +function openSettingsDialog() { + el("settings-cluster").value = window.__cfg?.clusterUri || ""; + el("settings-database").value = window.__cfg?.database || ""; + el("settings-error").textContent = ""; + el("settings-dialog").showModal(); + el("settings-cluster").focus(); +} + +/** POST the edited connection settings, then reconnect and re-query. */ +async function saveSettings() { + const clusterUri = el("settings-cluster").value.trim(); + const database = el("settings-database").value.trim(); + if (!clusterUri) { + el("settings-error").textContent = "Cluster URI is required."; + return; + } + const btn = el("settings-save"); + const original = btn.textContent; + btn.disabled = true; + btn.textContent = "Saving…"; + try { + const res = await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clusterUri, database: database || "Hub" }), + }); + const body = await res.json(); + if (!res.ok || body.error) throw new Error(body.error || "Save failed"); + window.__cfg = body; + el("settings-dialog").close(); + state.cache = {}; // stale data belongs to the old connection + load(); + } catch (err) { + el("settings-error").textContent = err.message || "Could not save settings."; + } finally { + btn.disabled = false; + btn.textContent = original; + } +} + +function wireControls() { + el("preset").addEventListener("click", (e) => { + const btn = e.target.closest("button[data-preset]"); + if (!btn || state.loading) return; + state.preset = btn.dataset.preset; + [...el("preset").querySelectorAll("button")].forEach((b) => b.classList.toggle("active", b === btn)); + void publishCanvasState({ preset: state.preset }); + load(); + }); + el("tabs").addEventListener("click", (e) => { + const btn = e.target.closest("button[data-tab]"); + if (btn) switchTab(btn.dataset.tab); + }); + el("refresh").addEventListener("click", () => { + if (state.loading) return; + if (state.cache[state.tab]) delete state.cache[state.tab][cacheKey()]; // force re-query + load(); + }); + + // Settings dialog controls + el("settings-open").addEventListener("click", openSettingsDialog); + el("settings-close").addEventListener("click", () => el("settings-dialog").close()); + el("settings-save").addEventListener("click", saveSettings); + + // KQL dialog controls + el("kql-close").addEventListener("click", () => el("kql-dialog").close()); + el("kql-copy").addEventListener("click", () => { + const btn = el("kql-copy"); + navigator.clipboard.writeText(el("kql-text").value) + .then(() => { btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); }) + .catch(() => { btn.textContent = "Failed"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); }); + }); + el("kql-run").addEventListener("click", executeKql); + + // KQL escape-hatch buttons (event delegation — buttons injected by panelHtml) + document.addEventListener("click", (e) => { + const capacityTab = e.target.closest("[data-capacity-class]"); + if (capacityTab) { + selectCapacityClass(capacityTab.dataset.capacityClass); + return; + } + const btn = e.target.closest(".kql-btn[data-panel-id]"); + if (btn) openKqlDialog(btn.dataset.panelId); + // hbar click-to-filter + const hbarRow = e.target.closest(".hbar-filterable[data-filter-dim]"); + if (hbarRow) { + const dim = hbarRow.dataset.filterDim; + const val = hbarRow.dataset.filterVal; + if (dim && val) toggleFilter(dim, val); + } + // chip remove + const chipRemove = e.target.closest(".chip-remove[data-dim]"); + if (chipRemove) { + const dim = chipRemove.dataset.dim; + const val = chipRemove.dataset.val; + if (dim && val) toggleFilter(dim, val); + } + // reset all + if (e.target.closest("#filter-reset")) clearFilters(); + }); + + document.addEventListener("change", (e) => { + const selector = e.target.closest("select[data-capacity-selector]"); + if (selector) applyCapacitySelection(selector.dataset.capacitySelector, selector.value); + }); + + // Keyboard activation and roving focus for interactive data controls. + document.addEventListener("keydown", (e) => { + const capacityTab = e.target.closest("[data-capacity-class]"); + if (capacityTab) { + if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { + e.preventDefault(); + moveCapacityTabFocus(capacityTab, e.key); + return; + } + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + selectCapacityClass(capacityTab.dataset.capacityClass); + return; + } + } + if (e.key !== "Enter" && e.key !== " ") return; + const hbarRow = e.target.closest(".hbar-filterable[data-filter-dim]"); + if (hbarRow) { + e.preventDefault(); + const dim = hbarRow.dataset.filterDim; + const val = hbarRow.dataset.filterVal; + if (dim && val) toggleFilter(dim, val); + } + }); + + let t; + window.addEventListener("resize", () => { clearTimeout(t); t = setTimeout(render, 180); }); +} + +async function init() { + try { + const [cfg, sharedState] = await Promise.all([ + fetch("/api/config").then((r) => r.json()), + fetch("/api/session-state").then((r) => r.json()), + ]); + window.__cfg = cfg; + if (Number.isInteger(sharedState.revision)) { + state.tab = sharedState.tab; + state.preset = sharedState.preset; + state.filters = sharedState.filters || {}; + state.capacityClass = sharedState.capacityClass || "home"; + state.capacitySelections = sharedState.capacitySelections || {}; + state.revision = sharedState.revision; + } + } catch { window.__cfg = {}; } + wireControls(); + syncCanvasControls(); + + // Restore tab from URL hash (bookmarking / back-forward support), or + // normalize the hash to reflect the default tab so the URL is always + // shareable. + const initialTab = tabFromHash(); + const initialCapacityClass = capacityClassFromHash(); + if (initialCapacityClass) state.capacityClass = initialCapacityClass; + const initialHash = (initialTab || state.tab) === "capacity" + ? `#tab=capacity&capacity=${state.capacityClass}` + : `#tab=${initialTab || state.tab}`; + if (initialTab && initialTab !== state.tab) { + switchTab(initialTab, { skipHash: true }); + history.replaceState({ tab: initialTab, capacityClass: state.capacityClass }, "", initialHash); + } else { + history.replaceState({ tab: state.tab, capacityClass: state.capacityClass }, "", initialHash); + revealActiveTab(); + load(); + } + + window.addEventListener("popstate", () => { + const tab = tabFromHash() || "overview"; + const capacityClass = capacityClassFromHash() || "home"; + if (tab === "capacity") state.capacityClass = capacityClass; + if (tab !== state.tab) switchTab(tab, { skipHash: true }); + else if (tab === "capacity") selectCapacityClass(capacityClass, { skipHash: true, skipPublish: true, force: true }); + }); + setInterval(pollCanvasState, 1000); +} + +if (typeof window !== "undefined" && typeof document !== "undefined") init(); diff --git a/.github/extensions/ftk-local-dashboard/public/index.html b/.github/extensions/ftk-local-dashboard/public/index.html new file mode 100644 index 000000000..8f152ea04 --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/public/index.html @@ -0,0 +1,97 @@ + + + + + + FinOps hub dashboard + + + +
+
+

FinOps hub dashboard

+

Connecting to the Hub database…

+
+
+
+ + + + +
+ + +
+
+ + + + + +
+
Loading cost data…
+
+ +
+ +
+ + Grounded in the FinOps Framework domains & the FinOps toolkit query catalog. +
+ + +
+
+

KQL query

+ +
+ + + +
+
+ + +
+
+

Connection settings

+ +
+
+ + +

Use a local loopback HTTP endpoint or a remote *.kusto.windows.net HTTPS cluster. Remote hubs use your current Azure CLI sign-in. Credentials are never saved.

+
+ + +
+
+ + + + diff --git a/.github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs b/.github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs new file mode 100644 index 000000000..a280bb32b --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs @@ -0,0 +1,785 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import test from "node:test"; + +process.env.FTK_LOCAL_DASHBOARD_TEST = "1"; + +const kusto = await import("../kusto.mjs"); +const extension = await import("../extension.mjs"); +const app = await import("../public/app.js"); + +const QUOTA_SCHEMA_FIELDS = [ + "ResourceId", "ResourceName", "SubAccountId", "location", "currentValue", + "limit", "unit", "x_SourceType", "x_SourceVersion", "x_IngestionTime", +]; +const COST_SCHEMA_FIELDS = [ + "ChargePeriodStart", "ProviderName", "ChargeCategory", "ResourceId", + "SubAccountId", "RegionId", "x_ResourceType", "x_SkuMeterCategory", + "x_SkuMeterSubcategory", "SkuMeter", "SkuPriceId", "EffectiveCost", + "BillingCurrency", "ConsumedQuantity", "ConsumedUnit", + "CapacityReservationId", "CapacityReservationStatus", +]; + +function kustoResponse(rows, columns = rows[0] ? Object.keys(rows[0]) : []) { + return new Response(JSON.stringify({ + Tables: [{ + TableName: "Table_0", + Columns: columns.map((ColumnName) => ({ ColumnName })), + Rows: rows.map((row) => columns.map((column) => row[column])), + }], + }), { headers: { "Content-Type": "application/json" } }); +} + +async function startCapacityServer(t, options = {}) { + const quotaFields = options.quotaFields || QUOTA_SCHEMA_FIELDS; + const costFields = options.costFields || COST_SCHEMA_FIELDS; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const { csl } = JSON.parse(body); + const fields = csl.startsWith("Quota() | getschema") + ? quotaFields + : csl.startsWith("Costs() | getschema") + ? costFields + : null; + const rows = fields + ? fields.map((ColumnName) => ({ ColumnName, ColumnType: "System.String" })) + : typeof options.rows === "function" + ? options.rows(csl) + : []; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(await kustoResponse(rows).text()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + return `http://127.0.0.1:${server.address().port}`; +} + +test("connection validation permits only local loopback or remote Kusto origins", () => { + assert.deepEqual( + kusto.normalizeConnection("http://LOCALHOST:8082/", " Hub "), + { clusterUri: "http://localhost:8082", database: "Hub", mode: "local", authentication: "none" } + ); + assert.equal( + kusto.normalizeConnection("https://example-cluster.westus.kusto.windows.net", "Hub").mode, + "remote" + ); + for (const uri of [ + "http://example.com", + "https://example.com", + "https://kusto.windows.net", + "https://user:pass@cluster.westus.kusto.windows.net", + "https://cluster.westus.kusto.windows.net/path", + "https://cluster.westus.kusto.windows.net?x=1", + ]) { + assert.throws(() => kusto.normalizeConnection(uri, "Hub")); + } +}); + +test("local dashboard semantics stay unauthenticated and preserve the payload shape", async (t) => { + const requests = []; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const { csl } = JSON.parse(body); + requests.push(req.headers); + const rows = csl.includes("MinDate=min") + ? [{ MinDate: "2025-01-01T00:00:00Z", MaxDate: "2025-04-01T00:00:00Z", Rows: 4 }] + : []; + const columns = rows[0] ? Object.keys(rows[0]) : []; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(await kustoResponse(rows, columns).text()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + + const { port } = server.address(); + const payload = await kusto.getDashboard(`http://127.0.0.1:${port}`, "Hub"); + assert.equal(payload.empty, false); + assert.deepEqual(Object.keys(payload.data), [ + "summary", "tagged", "pricing", "trend", "serviceCategory", + "topServices", "topResourceGroups", "topRegions", "chargeCategory", "macc", + ]); + assert.equal(requests.length, 11); + assert.ok(requests.every((headers) => headers.authorization === undefined)); + assert.ok(requests.every((headers) => headers["x-ms-readonly"] === "true")); +}); + +test("remote requests deduplicate tokens, add read-only headers, and recover after failure", async () => { + kusto.resetKustoAuthForTests(); + let providerCalls = 0; + let release; + const provider = async () => { + providerCalls++; + await new Promise((resolve) => { release = resolve; }); + return { accessToken: "secret-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }; + }; + const seen = []; + const fetchImpl = async (_url, options) => { + seen.push(options.headers); + return kustoResponse([{ Ready: 1 }]); + }; + const first = kusto.runQuery("https://cluster.westus.kusto.windows.net", "Hub", "print Ready=1", { tokenProvider: provider, fetchImpl }); + const second = kusto.runQuery("https://cluster.westus.kusto.windows.net", "Hub", "print Ready=1", { tokenProvider: provider, fetchImpl }); + await new Promise((resolve) => setImmediate(resolve)); + release(); + await Promise.all([first, second]); + assert.equal(providerCalls, 1); + assert.equal(seen.length, 2); + assert.ok(seen.every((headers) => headers.Authorization === "Bearer secret-token")); + assert.ok(seen.every((headers) => headers["x-ms-readonly"] === "true")); + assert.notEqual(seen[0]["x-ms-client-request-id"], seen[1]["x-ms-client-request-id"]); + + kusto.resetKustoAuthForTests(); + await assert.rejects(() => kusto.runQuery( + "https://cluster.westus.kusto.windows.net", + "Hub", + "print Ready=1", + { tokenProvider: async () => { throw new Error("temporary"); }, fetchImpl } + )); + await kusto.runQuery( + "https://cluster.westus.kusto.windows.net", + "Hub", + "print Ready=1", + { + tokenProvider: async () => ({ accessToken: "recovered", expires_on: Math.floor(Date.now() / 1000) + 3600 }), + fetchImpl, + } + ); +}); + +test("transport errors are actionable and authentication failures redact provider output", async () => { + await assert.rejects( + () => kusto.runQuery("http://localhost:8082", "Hub", "print Ready=1", { + fetchImpl: async () => { throw new Error("ECONNREFUSED"); }, + }), + /Could not reach Kusto at http:\/\/localhost:8082: ECONNREFUSED/ + ); + + kusto.resetKustoAuthForTests(); + await assert.rejects( + () => kusto.runQuery("https://cluster.westus.kusto.windows.net", "Hub", "print Ready=1", { + tokenProvider: async () => { throw new Error("secret-token-value"); }, + fetchImpl: async () => assert.fail("fetch must not run without a token"), + }), + (err) => /Azure CLI could not acquire/.test(err.message) && !err.message.includes("secret-token-value") + ); +}); + +test("response parsing detects partial failures and enforces the byte limit before parsing", async () => { + assert.throws(() => kusto.parseKustoResponse({ + Tables: [ + { TableName: "Table_0", Columns: [{ ColumnName: "Value" }], Rows: [[1]] }, + { + TableName: "Table_2", + Columns: [ + { ColumnName: "Severity" }, + { ColumnName: "StatusCode" }, + { ColumnName: "StatusDescription" }, + ], + Rows: [[2, -1, "Partial query failure"]], + }, + ], + }), /Partial query failure/); + await assert.rejects(() => kusto.readBoundedBody(new Response("12345"), 4), /4-byte limit/); +}); + +test("filter encoding keeps adversarial values inside one Kusto string literal", () => { + const values = [ + "O'Reilly", + "back\\slash", + "line\r\nbreak", + "x; .drop table Costs", + "// comment", + "/* comment */", + "東京", + ".show tables", + ]; + const where = kusto.buildFilterWhere({ ServiceName: values }); + for (const value of values) assert.ok(where.includes(JSON.stringify(value))); + assert.equal((where.match(/\| where/g) || []).length, 1); + assert.throws(() => kusto.buildFilterWhere({ BadColumn: ["x"] }), /Unsupported filter/); + assert.throws(() => kusto.validateFilters({ ServiceName: Array(9).fill("x") }), /at most 8/); +}); + +test("capacity registry is exact, versioned, and fail-closed", () => { + assert.equal(Object.keys(kusto.CAPACITY_CLASS_REGISTRY).length, 7); + assert.equal(Object.keys(kusto.CAPACITY_METRIC_REGISTRY).length, 3); + + const enabled = kusto.resolveCapacityMetric({ + x_SourceType: " computeusage ", + x_SourceVersion: "1.0-USAGE", + ResourceName: " CORES ", + unit: " count ", + }); + assert.equal(enabled.capability, "enabled"); + assert.equal(enabled.metricRole, "total-regional-vcpu"); + + assert.deepEqual( + kusto.resolveCapacityMetric({ + x_SourceType: "ComputeUsage", + x_SourceVersion: "1.0-usage", + ResourceName: "cores-extra", + unit: "Count", + }).capability, + "descriptive-only" + ); + assert.equal(kusto.resolveCapacityMetric({ + x_SourceType: "ComputeUsage", + x_SourceVersion: "2.0-usage", + ResourceName: "cores", + unit: "Count", + }).reasonCode, "source-version-mismatch"); + assert.equal(kusto.resolveCapacityMetric({ + x_SourceType: "AppServiceUsage", + x_SourceVersion: "1.0-usage", + ResourceName: "P1v3", + unit: "Instances", + }).reasonCode, "unclassified-metric"); + assert.equal(kusto.resolveCapacityMetric({ + x_SourceType: "UnknownUsage", + x_SourceVersion: "1.0", + ResourceName: "cores", + unit: "Count", + }).capability, "disabled"); +}); + +test("capacity observation precedence handles invalid, stale, unclassified, and limit states", () => { + const now = new Date("2026-08-23T12:00:00Z"); + const base = { + x_SourceType: "ComputeUsage", + x_SourceVersion: "1.0-usage", + ResourceName: "cores", + unit: "Count", + currentValue: 79, + limit: 100, + x_IngestionTime: "2026-08-23T10:00:00Z", + }; + assert.equal(kusto.classifyCapacityObservation(base, now).state, "healthy"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 80 }, now).state, "watch"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 90 }, now).state, "action"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 100 }, now).state, "exhausted"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 0, limit: 0 }, now).state, "no-entitlement"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 1, limit: 0 }, now).reasonCode, "conflicting-provider-values"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: null, x_IngestionTime: "2026-08-20T10:00:00Z" }, now).reasonCode, "invalid-provider-values"); + assert.equal(kusto.classifyCapacityObservation({ ...base, x_IngestionTime: "2026-08-21T12:00:00Z" }, now).state, "healthy"); + assert.equal(kusto.classifyCapacityObservation({ ...base, x_IngestionTime: "2026-08-21T11:59:59Z" }, now).state, "stale"); + + const unknown = { ...base, ResourceName: "regionalFamilyCores" }; + assert.equal(kusto.classifyCapacityObservation(unknown, now).state, "unclassified"); + assert.equal(kusto.classifyCapacityObservation({ ...unknown, x_IngestionTime: "2026-08-20T10:00:00Z" }, now).state, "stale"); + + const sqlNegative = kusto.classifyCapacityObservation({ + ...base, + x_SourceType: "SqlSubscriptionUsage", + x_SourceVersion: "1.0-sql", + ResourceName: "RegionalVCoreQuotaForSQLDBAndDW", + limit: -1, + }, now); + assert.equal(sqlNegative.state, "unclassified"); + assert.match(sqlNegative.evidenceLabel, /interpretation unverified/i); +}); + +test("inventory observations never receive quota arithmetic", () => { + const result = kusto.classifyCapacityObservation({ + x_SourceType: "PremiumSSDv2Disk", + x_SourceVersion: "1.0-disk", + ResourceId: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Compute/disks/d1", + ResourceName: "d1", + unit: "", + currentValue: 128, + limit: null, + x_IngestionTime: "2026-08-23T10:00:00Z", + }, new Date("2026-08-23T12:00:00Z")); + assert.equal(result.state, "inventory"); + assert.equal(result.currentValue, 128); + assert.equal(result.limit, null); + assert.equal(result.utilizationPercent, null); + assert.equal(result.headroom, null); +}); + +test("capacity history gates activate only supported evidence", () => { + assert.equal(kusto.resolveCapacityHistoryCapability(1).mode, "current-only"); + assert.equal(kusto.resolveCapacityHistoryCapability(2).mode, "observed-delta"); + assert.equal(kusto.resolveCapacityHistoryCapability(3).mode, "provisional-runway"); + assert.equal(kusto.resolveCapacityHistoryCapability(7).mode, "trend-runway"); + assert.equal(kusto.resolveCapacityHistoryCapability(7, { evidenceClass: "inventory" }).mode, "observed-history"); +}); + +test("capacity KQL is bounded and preserves semantic dimensions", () => { + const current = kusto.buildCapacityCurrentQuery("compute"); + const selectors = kusto.buildCapacitySelectorQuery("compute"); + const heatmap = kusto.buildCapacityHeatmapQuery("compute", { + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }); + const demand = kusto.buildCapacityDemandSelectorQuery("compute"); + const reconciliation = kusto.buildCapacityReservationReconciliationQuery(); + const disk = kusto.buildCapacityDemandSelectorQuery("premium-ssd-v2"); + + assert.match(current, /\| take 251$/); + assert.match(selectors, /\| take 501$/); + assert.match(heatmap, /\| take 501$/); + assert.match(demand, /\| take 501$/); + for (const dimension of ["ConsumedUnit", "x_SkuMeterCategory", "x_SkuMeterSubcategory", "SkuMeter", "SkuPriceId", "BillingCurrency"]) { + assert.ok(demand.includes(dimension)); + } + assert.match(reconciliation, /\| join kind=fullouter billed on GroupKey/); + assert.match(reconciliation, /inventory-only/); + assert.match(reconciliation, /cost-only/); + assert.match(disk, /\| join kind=leftouter diskCost on JoinResourceId/); + assert.match(disk, /InventoryResourceId/); +}); + +test("all seven capacity classes return the bounded payload contract", async (t) => { + const clusterUri = await startCapacityServer(t); + for (const classId of Object.keys(kusto.CAPACITY_CLASS_REGISTRY)) { + const payload = await kusto.getCapacity(clusterUri, "Hub", classId); + assert.equal(payload.classId, classId); + assert.equal(payload.contract.id, classId); + assert.equal(payload.schema.quota.available, true); + assert.equal(payload.schema.costs.available, true); + assert.equal(payload.table.rowLimit, 250); + assert.equal(payload.selectors.itemLimit, 500); + assert.equal(payload.history.pointLimit, 430); + assert.equal(payload.heatmap.limit, 500); + assert.equal(payload.series.pointLimit, 430); + assert.equal(payload.demand.selectors.itemLimit, 500); + } +}); + +test("missing schema fields disable only panels that depend on that source", async (t) => { + const missingQuota = await startCapacityServer(t, { + quotaFields: QUOTA_SCHEMA_FIELDS.filter((field) => field !== "x_SourceVersion"), + }); + const quotaPayload = await kusto.getCapacity(missingQuota, "Hub", "compute"); + assert.equal(quotaPayload.schema.quota.available, false); + assert.equal(quotaPayload.schema.costs.available, true); + assert.equal(quotaPayload.capability.mode, "disabled"); + assert.equal(quotaPayload.history.status, "disabled"); + assert.equal(quotaPayload.demand.capability.mode, "parallel"); + + const missingCost = await startCapacityServer(t, { + costFields: COST_SCHEMA_FIELDS.filter((field) => field !== "BillingCurrency"), + }); + const costPayload = await kusto.getCapacity(missingCost, "Hub", "compute"); + assert.equal(costPayload.schema.quota.available, true); + assert.equal(costPayload.schema.costs.available, false); + assert.equal(costPayload.capability.mode, "descriptive-only"); + assert.equal(costPayload.history.status, "no-selection"); + assert.equal(costPayload.demand.capability.mode, "disabled"); + assert.equal(costPayload.series.status, "disabled"); +}); + +test("capacity selections reject unknown fields and keys outside the selector catalog", async (t) => { + assert.throws( + () => extension.validateViewInput({ + name: "capacity", + capacityClass: "compute", + capacitySelections: { quotaSelection: { resourceName: "cores", injected: "value" } }, + }), + /Unsupported quotaSelection field/ + ); + assert.throws( + () => extension.validateViewInput({ name: "capacity", capacityClass: "unknown" }), + /Unsupported capacity class/ + ); + + const selectorRow = { + ResourceId: "/subscriptions/one/providers/Microsoft.Compute/locations/eastus/usages/cores", + ResourceName: "cores", + SubAccountId: "one", + location: "eastus", + unit: "Count", + x_SourceType: "ComputeUsage", + x_SourceVersion: "1.0-usage", + x_IngestionTime: "2026-08-23T12:00:00Z", + }; + const clusterUri = await startCapacityServer(t, { rows: () => [selectorRow] }); + await assert.rejects( + () => kusto.getCapacity(clusterUri, "Hub", "compute", { + quotaSelection: { + subAccountId: "one", + location: "westus", + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }, + }), + /not present in the bounded selector catalog/ + ); +}); + +test("capacity navigation, selection, and heatmap helpers preserve accessible text parity", () => { + assert.equal(app.nextCapacityTabIndex(0, "ArrowLeft"), 7); + assert.equal(app.nextCapacityTabIndex(7, "ArrowRight"), 0); + assert.equal(app.nextCapacityTabIndex(4, "Home"), 0); + assert.equal(app.nextCapacityTabIndex(2, "End"), 7); + assert.equal(app.nextCapacityTabIndex(3, "Enter"), 3); + + const sourceRow = { + SubAccountId: "subscription", + location: "eastus", + ResourceName: "cores", + unit: "Count", + x_SourceVersion: "1.0-usage", + }; + assert.deepEqual(app.capacitySelectionFromRow("quota", "compute", sourceRow), { + subAccountId: "subscription", + location: "eastus", + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }); + assert.deepEqual(app.capacitySelectionFromRow("metric", "compute", sourceRow), { + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }); + assert.deepEqual(app.capacityHeatmapCell({ + semantic: { utilizationPercent: 91.25, state: "action" }, + }, "compute"), { + value: 91.25, + text: "91.3%", + state: "action", + }); +}); + +test("capacity markup retains module loading, tab semantics, and visible text states", async () => { + const [html, source, css] = await Promise.all([ + readFile(new URL("../public/index.html", import.meta.url), "utf8"), + readFile(new URL("../public/app.js", import.meta.url), "utf8"), + readFile(new URL("../public/app.css", import.meta.url), "utf8"), + ]); + assert.match(html, /data-tab="capacity"/); + assert.match(html, / + + diff --git a/.github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs b/.github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs new file mode 100644 index 000000000..68854b9bb --- /dev/null +++ b/.github/extensions/ftk-local-dashboard/test/ftk-local-dashboard.test.mjs @@ -0,0 +1,815 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import test from "node:test"; + +process.env.FTK_LOCAL_DASHBOARD_TEST = "1"; + +const kusto = await import("../kusto.mjs"); +const extension = await import("../extension.mjs"); +const app = await import("../public/app.js"); + +const QUOTA_SCHEMA_FIELDS = [ + "ResourceId", "ResourceName", "SubAccountId", "location", "currentValue", + "limit", "unit", "x_SourceType", "x_SourceVersion", "x_IngestionTime", +]; +const COST_SCHEMA_FIELDS = [ + "ChargePeriodStart", "ProviderName", "ChargeCategory", "ResourceId", + "SubAccountId", "RegionId", "x_ResourceType", "x_SkuMeterCategory", + "x_SkuMeterSubcategory", "SkuMeter", "SkuPriceId", "EffectiveCost", + "BillingCurrency", "ConsumedQuantity", "ConsumedUnit", + "CapacityReservationId", "CapacityReservationStatus", +]; + +function kustoResponse(rows, columns = rows[0] ? Object.keys(rows[0]) : []) { + return new Response(JSON.stringify({ + Tables: [{ + TableName: "Table_0", + Columns: columns.map((ColumnName) => ({ ColumnName })), + Rows: rows.map((row) => columns.map((column) => row[column])), + }], + }), { headers: { "Content-Type": "application/json" } }); +} + +async function startCapacityServer(t, options = {}) { + const quotaFields = options.quotaFields || QUOTA_SCHEMA_FIELDS; + const costFields = options.costFields || COST_SCHEMA_FIELDS; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const { csl } = JSON.parse(body); + const fields = csl.startsWith("Quota() | getschema") + ? quotaFields + : csl.startsWith("Costs() | getschema") + ? costFields + : null; + const rows = fields + ? fields.map((ColumnName) => ({ ColumnName, ColumnType: "System.String" })) + : typeof options.rows === "function" + ? options.rows(csl) + : []; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(await kustoResponse(rows).text()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + return `http://127.0.0.1:${server.address().port}`; +} + +test("connection validation permits only local loopback or remote Kusto origins", () => { + assert.deepEqual( + kusto.normalizeConnection("http://LOCALHOST:8082/", " Hub "), + { clusterUri: "http://localhost:8082", database: "Hub", mode: "local", authentication: "none" } + ); + assert.equal( + kusto.normalizeConnection("https://example-cluster.westus.kusto.windows.net", "Hub").mode, + "remote" + ); + for (const uri of [ + "http://example.com", + "https://example.com", + "https://kusto.windows.net", + "https://user:pass@cluster.westus.kusto.windows.net", + "https://cluster.westus.kusto.windows.net/path", + "https://cluster.westus.kusto.windows.net?x=1", + ]) { + assert.throws(() => kusto.normalizeConnection(uri, "Hub")); + } +}); + +test("local dashboard semantics stay unauthenticated and preserve the payload shape", async (t) => { + const requests = []; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const { csl } = JSON.parse(body); + requests.push(req.headers); + const rows = csl.includes("MinDate=min") + ? [{ MinDate: "2025-01-01T00:00:00Z", MaxDate: "2025-04-01T00:00:00Z", Rows: 4 }] + : []; + const columns = rows[0] ? Object.keys(rows[0]) : []; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(await kustoResponse(rows, columns).text()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + + const { port } = server.address(); + const payload = await kusto.getDashboard(`http://127.0.0.1:${port}`, "Hub"); + assert.equal(payload.empty, false); + assert.deepEqual(Object.keys(payload.data), [ + "summary", "tagged", "pricing", "trend", "serviceCategory", + "topServices", "topResourceGroups", "topRegions", "chargeCategory", "macc", + ]); + assert.equal(requests.length, 11); + assert.ok(requests.every((headers) => headers.authorization === undefined)); + assert.ok(requests.every((headers) => headers["x-ms-readonly"] === "true")); +}); + +test("remote requests deduplicate tokens, add read-only headers, and recover after failure", async () => { + kusto.resetKustoAuthForTests(); + let providerCalls = 0; + let release; + const provider = async () => { + providerCalls++; + await new Promise((resolve) => { release = resolve; }); + return { accessToken: "secret-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }; + }; + const seen = []; + const fetchImpl = async (_url, options) => { + seen.push(options.headers); + return kustoResponse([{ Ready: 1 }]); + }; + const first = kusto.runQuery("https://cluster.westus.kusto.windows.net", "Hub", "print Ready=1", { tokenProvider: provider, fetchImpl }); + const second = kusto.runQuery("https://cluster.westus.kusto.windows.net", "Hub", "print Ready=1", { tokenProvider: provider, fetchImpl }); + await new Promise((resolve) => setImmediate(resolve)); + release(); + await Promise.all([first, second]); + assert.equal(providerCalls, 1); + assert.equal(seen.length, 2); + assert.ok(seen.every((headers) => headers.Authorization === "Bearer secret-token")); + assert.ok(seen.every((headers) => headers["x-ms-readonly"] === "true")); + assert.notEqual(seen[0]["x-ms-client-request-id"], seen[1]["x-ms-client-request-id"]); + + kusto.resetKustoAuthForTests(); + await assert.rejects(() => kusto.runQuery( + "https://cluster.westus.kusto.windows.net", + "Hub", + "print Ready=1", + { tokenProvider: async () => { throw new Error("temporary"); }, fetchImpl } + )); + await kusto.runQuery( + "https://cluster.westus.kusto.windows.net", + "Hub", + "print Ready=1", + { + tokenProvider: async () => ({ accessToken: "recovered", expires_on: Math.floor(Date.now() / 1000) + 3600 }), + fetchImpl, + } + ); +}); + +test("transport errors are actionable and authentication failures redact provider output", async () => { + await assert.rejects( + () => kusto.runQuery("http://localhost:8082", "Hub", "print Ready=1", { + fetchImpl: async () => { throw new Error("ECONNREFUSED"); }, + }), + /Could not reach Kusto at http:\/\/localhost:8082: ECONNREFUSED/ + ); + + kusto.resetKustoAuthForTests(); + await assert.rejects( + () => kusto.runQuery("https://cluster.westus.kusto.windows.net", "Hub", "print Ready=1", { + tokenProvider: async () => { throw new Error("secret-token-value"); }, + fetchImpl: async () => assert.fail("fetch must not run without a token"), + }), + (err) => /Azure CLI could not acquire/.test(err.message) && !err.message.includes("secret-token-value") + ); +}); + +test("response parsing detects partial failures and enforces the byte limit before parsing", async () => { + assert.throws(() => kusto.parseKustoResponse({ + Tables: [ + { TableName: "Table_0", Columns: [{ ColumnName: "Value" }], Rows: [[1]] }, + { + TableName: "Table_2", + Columns: [ + { ColumnName: "Severity" }, + { ColumnName: "StatusCode" }, + { ColumnName: "StatusDescription" }, + ], + Rows: [[2, -1, "Partial query failure"]], + }, + ], + }), /Partial query failure/); + await assert.rejects(() => kusto.readBoundedBody(new Response("12345"), 4), /4-byte limit/); +}); + +test("filter encoding keeps adversarial values inside one Kusto string literal", () => { + const values = [ + "O'Reilly", + "back\\slash", + "line\r\nbreak", + "x; .drop table Costs", + "// comment", + "/* comment */", + "東京", + ".show tables", + ]; + const where = kusto.buildFilterWhere({ ServiceName: values }); + for (const value of values) assert.ok(where.includes(JSON.stringify(value))); + assert.equal((where.match(/\| where/g) || []).length, 1); + assert.throws(() => kusto.buildFilterWhere({ BadColumn: ["x"] }), /Unsupported filter/); + assert.throws(() => kusto.validateFilters({ ServiceName: Array(9).fill("x") }), /at most 8/); +}); + +test("capacity registry is exact, versioned, and fail-closed", () => { + assert.equal(Object.keys(kusto.CAPACITY_CLASS_REGISTRY).length, 7); + assert.equal(Object.keys(kusto.CAPACITY_METRIC_REGISTRY).length, 3); + + const enabled = kusto.resolveCapacityMetric({ + x_SourceType: " computeusage ", + x_SourceVersion: "1.0-USAGE", + ResourceName: " CORES ", + unit: " count ", + }); + assert.equal(enabled.capability, "enabled"); + assert.equal(enabled.metricRole, "total-regional-vcpu"); + + assert.deepEqual( + kusto.resolveCapacityMetric({ + x_SourceType: "ComputeUsage", + x_SourceVersion: "1.0-usage", + ResourceName: "cores-extra", + unit: "Count", + }).capability, + "descriptive-only" + ); + assert.equal(kusto.resolveCapacityMetric({ + x_SourceType: "ComputeUsage", + x_SourceVersion: "2.0-usage", + ResourceName: "cores", + unit: "Count", + }).reasonCode, "source-version-mismatch"); + assert.equal(kusto.resolveCapacityMetric({ + x_SourceType: "AppServiceUsage", + x_SourceVersion: "1.0-usage", + ResourceName: "P1v3", + unit: "Instances", + }).reasonCode, "unclassified-metric"); + assert.equal(kusto.resolveCapacityMetric({ + x_SourceType: "UnknownUsage", + x_SourceVersion: "1.0", + ResourceName: "cores", + unit: "Count", + }).capability, "disabled"); +}); + +test("capacity observation precedence handles invalid, stale, unclassified, and limit states", () => { + const now = new Date("2026-08-23T12:00:00Z"); + const base = { + x_SourceType: "ComputeUsage", + x_SourceVersion: "1.0-usage", + ResourceName: "cores", + unit: "Count", + currentValue: 79, + limit: 100, + x_IngestionTime: "2026-08-23T10:00:00Z", + }; + assert.equal(kusto.classifyCapacityObservation(base, now).state, "healthy"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 80 }, now).state, "watch"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 90 }, now).state, "action"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 100 }, now).state, "exhausted"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 0, limit: 0 }, now).state, "no-entitlement"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: 1, limit: 0 }, now).reasonCode, "conflicting-provider-values"); + assert.equal(kusto.classifyCapacityObservation({ ...base, currentValue: null, x_IngestionTime: "2026-08-20T10:00:00Z" }, now).reasonCode, "invalid-provider-values"); + assert.equal(kusto.classifyCapacityObservation({ ...base, x_IngestionTime: "2026-08-21T12:00:00Z" }, now).state, "healthy"); + assert.equal(kusto.classifyCapacityObservation({ ...base, x_IngestionTime: "2026-08-21T11:59:59Z" }, now).state, "stale"); + + const unknown = { ...base, ResourceName: "regionalFamilyCores" }; + assert.equal(kusto.classifyCapacityObservation(unknown, now).state, "unclassified"); + assert.equal(kusto.classifyCapacityObservation({ ...unknown, x_IngestionTime: "2026-08-20T10:00:00Z" }, now).state, "stale"); + + const sqlNegative = kusto.classifyCapacityObservation({ + ...base, + x_SourceType: "SqlSubscriptionUsage", + x_SourceVersion: "1.0-sql", + ResourceName: "RegionalVCoreQuotaForSQLDBAndDW", + limit: -1, + }, now); + assert.equal(sqlNegative.state, "unclassified"); + assert.match(sqlNegative.sourceNote, /interpretation unverified/i); +}); + +test("inventory observations never receive quota arithmetic", () => { + const result = kusto.classifyCapacityObservation({ + x_SourceType: "PremiumSSDv2Disk", + x_SourceVersion: "1.0-disk", + ResourceId: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Compute/disks/d1", + ResourceName: "d1", + unit: "", + currentValue: 128, + limit: null, + x_IngestionTime: "2026-08-23T10:00:00Z", + }, new Date("2026-08-23T12:00:00Z")); + assert.equal(result.state, "inventory"); + assert.equal(result.currentValue, 128); + assert.equal(result.limit, null); + assert.equal(result.utilizationPercent, null); + assert.equal(result.headroom, null); +}); + +test("capacity history gates activate only supported readings", () => { + assert.equal(kusto.resolveCapacityHistoryCapability(1).mode, "current-only"); + assert.equal(kusto.resolveCapacityHistoryCapability(2).mode, "observed-delta"); + assert.equal(kusto.resolveCapacityHistoryCapability(3).mode, "provisional-runway"); + assert.equal(kusto.resolveCapacityHistoryCapability(7).mode, "trend-runway"); + assert.equal(kusto.resolveCapacityHistoryCapability(7, { quotaType: "inventory" }).mode, "observed-history"); +}); + +test("capacity KQL is bounded and preserves semantic dimensions", () => { + const current = kusto.buildCapacityCurrentQuery("compute"); + const selectors = kusto.buildCapacitySelectorQuery("compute"); + const heatmap = kusto.buildCapacityHeatmapQuery("compute", { + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }); + const demand = kusto.buildCapacityDemandSelectorQuery("compute"); + const reconciliation = kusto.buildCapacityReservationReconciliationQuery(); + const disk = kusto.buildCapacityDemandSelectorQuery("premium-ssd-v2"); + + assert.match(current, /\| take 251$/); + assert.match(selectors, /\| take 501$/); + assert.match(heatmap, /\| take 501$/); + assert.match(demand, /\| take 501$/); + for (const dimension of ["ConsumedUnit", "x_SkuMeterCategory", "x_SkuMeterSubcategory", "SkuMeter", "SkuPriceId", "BillingCurrency"]) { + assert.ok(demand.includes(dimension)); + } + assert.match(reconciliation, /\| join kind=fullouter billed on GroupKey/); + assert.match(reconciliation, /inventory-only/); + assert.match(reconciliation, /cost-only/); + assert.match(disk, /\| join kind=leftouter diskCost on JoinResourceId/); + assert.match(disk, /InventoryResourceId/); +}); + +test("all seven capacity classes return the bounded payload contract", async (t) => { + const clusterUri = await startCapacityServer(t); + for (const classId of Object.keys(kusto.CAPACITY_CLASS_REGISTRY)) { + const payload = await kusto.getCapacity(clusterUri, "Hub", classId); + assert.equal(payload.classId, classId); + assert.equal(payload.contract.id, classId); + assert.equal(payload.schema.quota.available, true); + assert.equal(payload.schema.costs.available, true); + assert.equal(payload.table.rowLimit, 250); + assert.equal(payload.selectors.itemLimit, 500); + assert.equal(payload.history.pointLimit, 430); + assert.equal(payload.heatmap.limit, 500); + assert.equal(payload.series.pointLimit, 430); + assert.equal(payload.demand.selectors.itemLimit, 500); + } +}); + +test("missing schema fields disable only panels that depend on that source", async (t) => { + const missingQuota = await startCapacityServer(t, { + quotaFields: QUOTA_SCHEMA_FIELDS.filter((field) => field !== "x_SourceVersion"), + }); + const quotaPayload = await kusto.getCapacity(missingQuota, "Hub", "compute"); + assert.equal(quotaPayload.schema.quota.available, false); + assert.equal(quotaPayload.schema.costs.available, true); + assert.equal(quotaPayload.capability.mode, "disabled"); + assert.equal(quotaPayload.history.status, "disabled"); + assert.equal(quotaPayload.demand.capability.mode, "parallel"); + + const missingCost = await startCapacityServer(t, { + costFields: COST_SCHEMA_FIELDS.filter((field) => field !== "BillingCurrency"), + }); + const costPayload = await kusto.getCapacity(missingCost, "Hub", "compute"); + assert.equal(costPayload.schema.quota.available, true); + assert.equal(costPayload.schema.costs.available, false); + assert.equal(costPayload.capability.mode, "descriptive-only"); + assert.equal(costPayload.history.status, "no-selection"); + assert.equal(costPayload.demand.capability.mode, "disabled"); + assert.equal(costPayload.series.status, "disabled"); +}); + +test("capacity selections reject unknown fields and keys outside the selector catalog", async (t) => { + assert.throws( + () => extension.validateViewInput({ + name: "capacity", + capacityClass: "compute", + capacitySelections: { quotaSelection: { resourceName: "cores", injected: "value" } }, + }), + /Unsupported quotaSelection field/ + ); + assert.throws( + () => extension.validateViewInput({ name: "capacity", capacityClass: "unknown" }), + /Unsupported capacity class/ + ); + + const selectorRow = { + ResourceId: "/subscriptions/one/providers/Microsoft.Compute/locations/eastus/usages/cores", + ResourceName: "cores", + SubAccountId: "one", + location: "eastus", + unit: "Count", + x_SourceType: "ComputeUsage", + x_SourceVersion: "1.0-usage", + x_IngestionTime: "2026-08-23T12:00:00Z", + }; + const clusterUri = await startCapacityServer(t, { rows: () => [selectorRow] }); + await assert.rejects( + () => kusto.getCapacity(clusterUri, "Hub", "compute", { + quotaSelection: { + subAccountId: "one", + location: "westus", + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }, + }), + /not present in the bounded selector catalog/ + ); +}); + +test("capacity navigation, selection, and heatmap helpers preserve accessible text parity", () => { + assert.equal(app.nextCapacityTabIndex(0, "ArrowLeft"), 7); + assert.equal(app.nextCapacityTabIndex(7, "ArrowRight"), 0); + assert.equal(app.nextCapacityTabIndex(4, "Home"), 0); + assert.equal(app.nextCapacityTabIndex(2, "End"), 7); + assert.equal(app.nextCapacityTabIndex(3, "Enter"), 3); + + const sourceRow = { + SubAccountId: "subscription", + location: "eastus", + ResourceName: "cores", + unit: "Count", + x_SourceVersion: "1.0-usage", + }; + assert.deepEqual(app.capacitySelectionFromRow("quota", "compute", sourceRow), { + subAccountId: "subscription", + location: "eastus", + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }); + assert.deepEqual(app.capacitySelectionFromRow("metric", "compute", sourceRow), { + resourceName: "cores", + unit: "Count", + sourceVersion: "1.0-usage", + }); + assert.deepEqual(app.capacityHeatmapCell({ + semantic: { utilizationPercent: 91.25, state: "action" }, + }, "compute"), { + value: 91.25, + text: "91.3%", + state: "action", + }); +}); + +test("capacity markup retains module loading, tab semantics, and visible text states", async () => { + const [html, source, css] = await Promise.all([ + readFile(new URL("../public/index.html", import.meta.url), "utf8"), + readFile(new URL("../public/app.js", import.meta.url), "utf8"), + readFile(new URL("../public/app.css", import.meta.url), "utf8"), + ]); + assert.match(html, /data-tab="capacity"/); + assert.match(html, /