modules/Devolutions.CIEM.PSU/Pages/New-CIEMEnvironmentPage.ps1
|
function New-CIEMEnvironmentPage { <# .SYNOPSIS Creates the Environment Explorer page with an interactive ARM hierarchy tree. .DESCRIPTION Renders a visual hierarchical diagram of the cloud environment using ECharts. Users select a provider (Azure), load the hierarchy, and interactively expand/collapse nodes to explore Tenant -> Subscription -> ResourceGroup -> Resource relationships. .PARAMETER Navigation Array of UDListItem components for sidebar navigation. #> [CmdletBinding()] param( [Parameter(Mandatory)] [object[]]$Navigation ) New-UDPage -Name 'Environment' -Url '/ciem/environment' -Content { Write-CIEMLog -Message "Environment page Content block executing" -Severity INFO -Component 'PSU-EnvironmentPage' # Load ECharts community library from CDN New-UDHelmet -Tag 'script' -Attributes @{ src = 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js' type = 'text/javascript' } New-UDTypography -Text 'Environment Explorer' -Variant 'h4' -Style @{ marginBottom = '10px'; marginTop = '10px' } New-UDTypography -Text 'Explore your cloud infrastructure hierarchy - expand and collapse nodes to navigate resources' -Variant 'subtitle1' -Style @{ marginBottom = '20px'; color = '#666' } # Provider selector + Layout toggle + Discovery button New-UDElement -Tag 'div' -Attributes @{ style = @{ marginBottom = '20px' } } -Content { New-UDStack -Direction 'row' -Spacing 2 -AlignItems 'center' -Content { New-UDElement -Tag 'div' -Attributes @{ style = @{ minWidth = '200px' } } -Content { New-UDSelect -Id 'envProviderSelect' -Label 'Provider' -Option { New-UDSelectOption -Name 'Azure' -Value 'Azure' } -DefaultValue 'Azure' -OnChange { $Session:SelectedEnvProvider = $EventData } } New-UDElement -Tag 'div' -Attributes @{ style = @{ minWidth = '180px' } } -Content { New-UDSelect -Id 'envViewSelect' -Label 'View' -Option { New-UDSelectOption -Name 'Infrastructure' -Value 'Infrastructure' New-UDSelectOption -Name 'Identity' -Value 'Identity' } -DefaultValue 'Infrastructure' -OnChange { $Session:SelectedEnvView = $EventData Sync-UDElement -Id 'envChartDynamic' } } New-UDElement -Tag 'div' -Attributes @{ style = @{ minWidth = '180px' } } -Content { New-UDSelect -Id 'envOrientSelect' -Label 'Layout' -Option { New-UDSelectOption -Name 'Left to Right' -Value 'LR' New-UDSelectOption -Name 'Top to Bottom' -Value 'TB' } -DefaultValue 'LR' -OnChange { $Session:SelectedEnvOrient = $EventData Sync-UDElement -Id 'envChartDynamic' } } New-UDButton -Id 'startDiscoveryBtn' -Text 'Start Discovery' -Variant 'outlined' -Color 'secondary' -ShowLoading -OnClick { try { $provider = $Session:SelectedEnvProvider if (-not $provider) { $provider = 'Azure' } Write-CIEMLog -Message "DISCOVERY ONCLICK: entered handler, provider=$provider" -Severity INFO -Component 'PSU-EnvironmentPage' if ($provider -ne 'Azure') { Show-UDToast -Message "Provider '$provider' is not yet supported for discovery." -Duration 5000 -BackgroundColor '#ff9800' return } Show-UDToast -Message 'Starting Azure discovery...' -Duration 5000 -BackgroundColor '#2196f3' $run = Invoke-CIEMJobWithProgress ` -ScriptName 'Devolutions.CIEM\Start-CIEMAzureDiscovery' ` -ProgressElementId 'envDiscoveryProgress' ` -DisableElementIds @('startDiscoveryBtn') ` -MaxPollSeconds 600 Write-CIEMLog -Message "DISCOVERY ONCLICK: Invoke-CIEMJobWithProgress returned, run type=$($run.GetType().Name), run=$($run | ConvertTo-Json -Depth 2 -Compress -ErrorAction SilentlyContinue)" -Severity INFO -Component 'PSU-EnvironmentPage' $status = $run.Status $armCount = $run.ArmRowCount $entraCount = $run.EntraRowCount Write-CIEMLog -Message "DISCOVERY ONCLICK: parsed results — status=$status, arm=$armCount, entra=$entraCount" -Severity INFO -Component 'PSU-EnvironmentPage' # Clear discovery progress and auto-reload the environment tree Set-UDElement -Id 'envDiscoveryProgress' -Content {} Write-CIEMLog -Message "DISCOVERY ONCLICK: calling Sync-UDElement envChartDynamic" -Severity INFO -Component 'PSU-EnvironmentPage' Sync-UDElement -Id 'envChartDynamic' Write-CIEMLog -Message "DISCOVERY ONCLICK: Sync-UDElement returned" -Severity INFO -Component 'PSU-EnvironmentPage' if ($status -eq 'Completed') { Show-UDToast -Message "Discovery completed: $armCount ARM resources, $entraCount Entra resources" -Duration 8000 -BackgroundColor '#4caf50' } elseif ($status -eq 'Partial') { Show-UDToast -Message "Discovery partially completed: $armCount ARM, $entraCount Entra (some warnings)" -Duration 8000 -BackgroundColor '#ff9800' } else { Show-UDToast -Message "Discovery finished with status: $status" -Duration 8000 -BackgroundColor '#ff9800' } } catch { $errorMsg = $_.Exception.Message Write-CIEMLog -Message "Discovery from Environment page failed: $errorMsg" -Severity ERROR -Component 'PSU-EnvironmentPage' Set-UDElement -Id 'envDiscoveryProgress' -Content {} Show-UDToast -Message "Discovery failed: $errorMsg" -Duration 8000 -BackgroundColor '#f44336' } } } } # Discovery progress container (separate from chart area to avoid destroying New-UDDynamic) New-UDElement -Tag 'div' -Id 'envDiscoveryProgress' -Content {} # Chart area — outer wrapper preserves #envChartArea for E2E selectors New-UDElement -Tag 'div' -Id 'envChartArea' -Content { New-UDDynamic -Id 'envChartDynamic' -LoadingComponent { New-UDCard -Style @{ textAlign = 'center'; padding = '40px' } -Content { New-UDProgress -Circular New-UDTypography -Text 'Loading environment data...' -Variant 'body1' -Style @{ marginTop = '16px'; color = '#666' } } } -Content { Write-CIEMLog -Message "DYNAMIC CONTENT: envChartDynamic Content block entered" -Severity INFO -Component 'PSU-EnvironmentPage' $loadedModule = Get-Module 'Devolutions.CIEM' | Select-Object -First 1 Write-CIEMLog -Message "ENV PAGE: Loaded module version=$($loadedModule.Version), path=$($loadedModule.ModuleBase)" -Severity INFO -Component 'PSU-EnvironmentPage' Write-CIEMLog -Message "ENV PAGE: DatabasePath=$script:DatabasePath" -Severity INFO -Component 'PSU-EnvironmentPage' Write-CIEMLog -Message "ENV PAGE: ModuleRoot=$script:ModuleRoot" -Severity INFO -Component 'PSU-EnvironmentPage' try { $orient = $Session:SelectedEnvOrient if (-not $orient) { $orient = 'LR' } $viewMode = $Session:SelectedEnvView if (-not $viewMode) { $viewMode = 'Infrastructure' } Write-CIEMLog -Message "DYNAMIC CONTENT: view=$viewMode, orient=$orient" -Severity INFO -Component 'PSU-EnvironmentPage' if ($viewMode -eq 'Identity') { # --- Identity View --- $assignmentMode = $Session:SelectedEnvAssignmentMode if (-not $assignmentMode) { $assignmentMode = 'Effective' } # Assignment mode sub-toggle New-UDElement -Tag 'div' -Attributes @{ style = @{ marginBottom = '12px' } } -Content { New-UDElement -Tag 'div' -Attributes @{ style = @{ minWidth = '250px'; maxWidth = '300px' } } -Content { New-UDSelect -Id 'envAssignmentModeSelect' -Label 'Assignment Mode' -Option { New-UDSelectOption -Name 'Effective (Group Expanded)' -Value 'Effective' New-UDSelectOption -Name 'Direct Only' -Value 'Direct' } -DefaultValue $assignmentMode -OnChange { $Session:SelectedEnvAssignmentMode = $EventData Sync-UDElement -Id 'envChartDynamic' } } } Write-CIEMLog -Message "DYNAMIC CONTENT: calling Get-CIEMAzureIdentityHierarchy (mode: $assignmentMode)" -Severity INFO -Component 'PSU-EnvironmentPage' $hierarchy = @(Get-CIEMAzureIdentityHierarchy -Mode $assignmentMode) Write-CIEMLog -Message "DYNAMIC CONTENT: got $($hierarchy.Count) identity hierarchy nodes" -Severity INFO -Component 'PSU-EnvironmentPage' # Identity-specific summary counts $identityCount = @($hierarchy | Where-Object { $_.NodeType -eq 'Identity' }).Count $roleCount = @($hierarchy | Where-Object { $_.NodeType -eq 'Role' }).Count $scopeCount = @($hierarchy | Where-Object { $_.NodeType -eq 'Scope' }).Count $typeCount = @($hierarchy | Where-Object { $_.NodeType -eq 'IdentityType' }).Count New-UDCard -Style @{ marginBottom = '16px'; backgroundColor = '#f5f5f5' } -Content { New-UDStack -Direction 'row' -Spacing 4 -AlignItems 'center' -Content { New-UDElement -Tag 'div' -Content { New-UDTypography -Text 'Identity Types' -Variant 'caption' -Style @{ color = '#666' } New-UDTypography -Text "$typeCount" -Variant 'h6' -Style @{ color = '#7b1fa2' } } New-UDElement -Tag 'div' -Content { New-UDTypography -Text 'Identities' -Variant 'caption' -Style @{ color = '#666' } New-UDTypography -Text "$identityCount" -Variant 'h6' -Style @{ color = '#1565c0' } } New-UDElement -Tag 'div' -Content { New-UDTypography -Text 'Roles' -Variant 'caption' -Style @{ color = '#666' } New-UDTypography -Text "$roleCount" -Variant 'h6' -Style @{ color = '#00838f' } } New-UDElement -Tag 'div' -Content { New-UDTypography -Text 'Scopes' -Variant 'caption' -Style @{ color = '#666' } New-UDTypography -Text "$scopeCount" -Variant 'h6' -Style @{ color = '#558b2f' } } } } } else { # --- Infrastructure View --- Write-CIEMLog -Message "DYNAMIC CONTENT: calling Get-CIEMAzureArmHierarchy (orient: $orient)" -Severity INFO -Component 'PSU-EnvironmentPage' $hierarchy = @(Get-CIEMAzureArmHierarchy) Write-CIEMLog -Message "DYNAMIC CONTENT: got $($hierarchy.Count) hierarchy nodes" -Severity INFO -Component 'PSU-EnvironmentPage' # Summary counts $tenantCount = @($hierarchy | Where-Object { $_.NodeType -eq 'Tenant' }).Count $subCount = @($hierarchy | Where-Object { $_.NodeType -eq 'Subscription' }).Count $rgCount = @($hierarchy | Where-Object { $_.NodeType -eq 'ResourceGroup' }).Count $resCount = @($hierarchy | Where-Object { $_.NodeType -eq 'Resource' }).Count New-UDCard -Style @{ marginBottom = '16px'; backgroundColor = '#f5f5f5' } -Content { New-UDStack -Direction 'row' -Spacing 4 -AlignItems 'center' -Content { New-UDElement -Tag 'div' -Content { New-UDTypography -Text 'Tenants' -Variant 'caption' -Style @{ color = '#666' } New-UDTypography -Text "$tenantCount" -Variant 'h6' -Style @{ color = '#1565c0' } } New-UDElement -Tag 'div' -Content { New-UDTypography -Text 'Subscriptions' -Variant 'caption' -Style @{ color = '#666' } New-UDTypography -Text "$subCount" -Variant 'h6' -Style @{ color = '#2e7d32' } } New-UDElement -Tag 'div' -Content { New-UDTypography -Text 'Resource Groups' -Variant 'caption' -Style @{ color = '#666' } New-UDTypography -Text "$rgCount" -Variant 'h6' -Style @{ color = '#e65100' } } New-UDElement -Tag 'div' -Content { New-UDTypography -Text 'Resources' -Variant 'caption' -Style @{ color = '#666' } New-UDTypography -Text "$resCount" -Variant 'h6' -Style @{ color = '#546e7a' } } } } } # --- Convert flat hierarchy to nested ECharts tree data --- # Resource type → icon mapping $resourceTypeIcons = @{ 'microsoft.compute/virtualmachines' = [char]::ConvertFromUtf32(0x1F5A5) # desktop computer 'microsoft.compute/virtualmachines/extensions' = [char]::ConvertFromUtf32(0x1F9E9) # puzzle piece 'microsoft.compute/disks' = [char]::ConvertFromUtf32(0x1F4BF) # optical disc 'microsoft.keyvault/vaults' = [char]::ConvertFromUtf32(0x1F511) # key 'microsoft.storage/storageaccounts' = [char]::ConvertFromUtf32(0x1F4E6) # package 'microsoft.network/virtualnetworks' = [char]::ConvertFromUtf32(0x1F310) # globe with meridians 'microsoft.network/networksecuritygroups' = [char]::ConvertFromUtf32(0x1F6E1) # shield 'microsoft.network/networkinterfaces' = [char]::ConvertFromUtf32(0x1F50C) # electric plug 'microsoft.network/publicipaddresses' = [char]::ConvertFromUtf32(0x1F4CD) # round pushpin 'microsoft.network/networkwatchers' = [char]::ConvertFromUtf32(0x1F441) # eye 'microsoft.web/sites' = [char]::ConvertFromUtf32(0x1F310) # globe 'microsoft.web/sites/slots' = [char]::ConvertFromUtf32(0x1F310) # globe 'microsoft.web/serverfarms' = [char]::ConvertFromUtf32(0x1F4CB) # clipboard 'microsoft.web/staticsites' = [char]::ConvertFromUtf32(0x1F4C4) # page facing up 'microsoft.web/certificates' = [char]::ConvertFromUtf32(0x1F4DC) # scroll 'microsoft.web/customapis' = [char]::ConvertFromUtf32(0x1F517) # link 'microsoft.sql/servers' = [char]::ConvertFromUtf32(0x1F5C4) # file cabinet 'microsoft.sql/servers/databases' = [char]::ConvertFromUtf32(0x1F5C3) # card file box 'microsoft.sqlvirtualmachine/sqlvirtualmachines' = [char]::ConvertFromUtf32(0x1F5C4) # file cabinet 'microsoft.cognitiveservices/accounts' = [char]::ConvertFromUtf32(0x1F9E0) # brain 'microsoft.logic/workflows' = [char]::ConvertFromUtf32(0x1F504) # arrows counterclockwise 'microsoft.insights/components' = [char]::ConvertFromUtf32(0x1F4CA) # bar chart 'microsoft.insights/actiongroups' = [char]::ConvertFromUtf32(0x1F514) # bell 'microsoft.operationalinsights/workspaces' = [char]::ConvertFromUtf32(0x1F4CA) # bar chart 'microsoft.alertsmanagement/smartdetectoralertrules' = [char]::ConvertFromUtf32(0x1F6A8) # rotating light 'microsoft.managedidentity/userassignedidentities' = [char]::ConvertFromUtf32(0x1F464) # bust in silhouette 'microsoft.authorization/roledefinitions' = [char]::ConvertFromUtf32(0x1F4DC) # scroll 'microsoft.authorization/roleassignments' = [char]::ConvertFromUtf32(0x1F465) # busts in silhouette 'microsoft.portal/dashboards' = [char]::ConvertFromUtf32(0x1F4CA) # bar chart 'microsoft.powerplatform/accounts' = [char]::ConvertFromUtf32(0x26A1) # high voltage 'microsoft.migrate/movecollections' = [char]::ConvertFromUtf32(0x1F4E4) # outbox tray 'microsoft.devtestlab/schedules' = [char]::ConvertFromUtf32(0x1F552) # clock } # Node type styling table (color, default icon, size) $nodeStyles = @{ 'Tenant' = @{ Color = '#42a5f5'; Icon = [char]::ConvertFromUtf32(0x1F3E2); Size = 32 } 'Subscription' = @{ Color = '#66bb6a'; Icon = [char]::ConvertFromUtf32(0x1F4CB); Size = 26 } 'ResourceGroup' = @{ Color = '#ffa726'; Icon = [char]::ConvertFromUtf32(0x1F4C1); Size = 22 } 'Category' = @{ Color = '#ab47bc'; Icon = [char]::ConvertFromUtf32(0x1F512); Size = 22 } 'Resource' = @{ Color = '#78909c'; Icon = [char]0x2699; Size = 16 } } # Identity view node types $nodeStyles['IdentityType'] = @{ Color = '#7b1fa2'; Icon = [char]::ConvertFromUtf32(0x1F465); Size = 26 } # busts in silhouette $nodeStyles['Identity'] = @{ Color = '#1565c0'; Icon = [char]::ConvertFromUtf32(0x1F464); Size = 22 } # bust in silhouette $nodeStyles['Role'] = @{ Color = '#00838f'; Icon = [char]::ConvertFromUtf32(0x1F511); Size = 20 } # key $nodeStyles['Scope'] = @{ Color = '#558b2f'; Icon = [char]::ConvertFromUtf32(0x1F3AF); Size = 16 } # target/bullseye $defaultStyle = @{ Color = '#bdbdbd'; Icon = [char]0x25CF; Size = 18 } $lookup = @{} foreach ($node in $hierarchy) { $style = $nodeStyles[$node.NodeType] ?? $defaultStyle $nodeColor = $style.Color $nodeSize = $style.Size # Resolve icon: use resource-type-specific icon for Category/Resource nodes, else default $resType = if ($node.ResourceType) { $node.ResourceType } elseif ($node.Resource) { $node.Resource.Type } else { $null } $nodeIcon = if ($resType -and $resourceTypeIcons[$resType]) { $resourceTypeIcons[$resType] } else { $style.Icon } $tooltipParts = @($node.NodeType) if ($node.NodeType -eq 'Resource' -and $node.Resource) { $tooltipParts += $node.Resource.Type if ($node.Resource.Location) { $tooltipParts += $node.Resource.Location } } $lookup[$node.NodeId] = @{ name = "$nodeIcon $($node.Label)" value = @{ nodeType = $node.NodeType tooltip = ($tooltipParts -join '|') } symbolSize = $nodeSize itemStyle = @{ color = $nodeColor; borderColor = $nodeColor } children = [System.Collections.Generic.List[object]]::new() } } $roots = [System.Collections.Generic.List[object]]::new() foreach ($node in $hierarchy) { $entry = $lookup[$node.NodeId] if ($node.ParentNodeId -and $lookup.ContainsKey($node.ParentNodeId)) { $lookup[$node.ParentNodeId].children.Add($entry) } else { $roots.Add($entry) } } $treeRoot = if ($roots.Count -eq 1) { $roots[0] } else { @{ name = "$([char]0x2601) Cloud Environment" symbolSize = 34 itemStyle = @{ color = '#90a4ae'; borderColor = '#90a4ae' } children = $roots } } $treeJson = $treeRoot | ConvertTo-Json -Depth 20 -Compress # Render the chart container New-UDCard -Content { New-UDHtml -Markup '<div id="ciemEnvTreeContainer" style="width:100%;height:700px;"></div>' } # Render the ECharts tree via browser JavaScript # NOTE: @"..."@ here-string interpolates $treeJson and $orient from PowerShell; # the JS code itself contains no $ variables so no false interpolation. $js = @" (function() { var attempts = 0; function tryRender() { if (typeof echarts === 'undefined') { attempts++; if (attempts < 30) { setTimeout(tryRender, 200); return; } console.error('CIEM: ECharts library failed to load from CDN'); return; } var container = document.getElementById('ciemEnvTreeContainer'); if (!container) { attempts++; if (attempts < 30) { setTimeout(tryRender, 200); return; } console.error('CIEM: Tree container element not found'); return; } var existing = echarts.getInstanceByDom(container); if (existing) existing.dispose(); var chart = echarts.init(container, 'dark'); var data = ${treeJson}; var isLR = '$orient' === 'LR'; chart.setOption({ backgroundColor: 'transparent', tooltip: { trigger: 'item', triggerOn: 'mousemove', confine: true, formatter: function(params) { var d = params.data.value || {}; var lines = ['<b>' + params.name + '</b>']; var parts = (d.tooltip || '').split('|'); for (var i = 0; i < parts.length; i++) { if (parts[i]) lines.push(parts[i]); } return lines.join('<br/>'); } }, series: [{ type: 'tree', data: [data], top: isLR ? '2%' : '8%', left: isLR ? '18%' : '2%', bottom: isLR ? '2%' : '20%', right: isLR ? '20%' : '2%', symbolSize: function(value, params) { return params.data.symbolSize || 10; }, orient: '$orient', label: { show: true, position: isLR ? 'left' : 'top', verticalAlign: 'middle', align: isLR ? 'right' : 'center', fontSize: 16, fontFamily: '"Roboto","Helvetica","Arial",sans-serif', color: '#e0e0e0', formatter: function(params) { var name = params.name || ''; if (name.length > 35) return name.substring(0, 32) + '...'; return name; } }, leaves: { label: { position: isLR ? 'right' : 'bottom', verticalAlign: 'middle', align: isLR ? 'left' : 'center' } }, lineStyle: { color: '#555', width: 1.5, curveness: 0.5 }, emphasis: { focus: 'descendant', itemStyle: { borderWidth: 2 }, label: { color: '#fff', fontSize: 17 } }, expandAndCollapse: true, initialTreeDepth: 2, animationDuration: 550, animationDurationUpdate: 750 }] }); window.addEventListener('resize', function() { chart.resize(); }); } tryRender(); })(); "@ Invoke-UDJavaScript -JavaScript $js Write-CIEMLog -Message "Environment tree rendered: $resCount resources, $subCount subs, $rgCount RGs" -Severity INFO -Component 'PSU-EnvironmentPage' } catch { $errorMsg = $_.Exception.Message Write-CIEMLog -Message "Environment auto-load failed: $errorMsg" -Severity ERROR -Component 'PSU-EnvironmentPage' if ($errorMsg -match 'No ARM resources found' -or $errorMsg -match 'No effective role assignments' -or $errorMsg -match 'No role assignment resources' -or $errorMsg -match 'No valid role assignment') { New-UDCard -Style @{ textAlign = 'center'; padding = '40px' } -Content { New-UDStack -Direction 'column' -AlignItems 'center' -Spacing 3 -Content { New-UDIcon -Icon 'Database' -Size '3x' -Style @{ color = '#ff9800'; marginBottom = '16px' } New-UDTypography -Text 'No Data Discovered' -Variant 'h5' -Style @{ marginBottom = '8px' } New-UDTypography -Text 'Run Azure discovery first to populate resource and identity data, then return here to explore.' -Variant 'body1' -Style @{ color = '#666' } } } } else { New-CIEMErrorContent -Text 'Failed to Load Environment' -Details $errorMsg } } } } } -Navigation $Navigation -NavigationLayout permanent } |