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 ) $ErrorActionPreference = 'Stop' New-UDPage -Name 'Environment' -Url '/ciem/environment' -Content { Devolutions.CIEM\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 'Identities' -Value 'Identities' } -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' } Devolutions.CIEM\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' # Progress is shown by the auto-refresh status banner above — no ProgressElementId needed $run = Devolutions.CIEM\Invoke-CIEMJobWithProgress ` -ScriptName 'Checks/Start-CIEMAzureDiscovery' ` -DisableElementIds @('startDiscoveryBtn') ` -MaxPollSeconds 600 if (-not $run) { Devolutions.CIEM\Write-CIEMLog -Message "DISCOVERY ONCLICK: Invoke-CIEMJobWithProgress returned null — job produced no pipeline output" -Severity WARNING -Component 'PSU-EnvironmentPage' Sync-UDElement -Id 'envChartDynamic' Show-UDToast -Message "Discovery completed but returned no output — check logs for warnings" -Duration 8000 -BackgroundColor '#ff9800' return } Devolutions.CIEM\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 Devolutions.CIEM\Write-CIEMLog -Message "DISCOVERY ONCLICK: parsed results — status=$status, arm=$armCount, entra=$entraCount" -Severity INFO -Component 'PSU-EnvironmentPage' # Auto-reload the environment tree Devolutions.CIEM\Write-CIEMLog -Message "DISCOVERY ONCLICK: calling Sync-UDElement envChartDynamic" -Severity INFO -Component 'PSU-EnvironmentPage' Sync-UDElement -Id 'envChartDynamic' Devolutions.CIEM\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 Devolutions.CIEM\Write-CIEMLog -Message "Discovery from Environment page failed: $errorMsg" -Severity ERROR -Component 'PSU-EnvironmentPage' Show-UDToast -Message "Discovery failed: $errorMsg" -Duration 8000 -BackgroundColor '#f44336' } } } } # Discovery status banner — auto-refreshes to show running discovery with cancel button New-UDElement -Tag 'div' -Id 'envDiscoveryStatusBanner' -Content { New-UDDynamic -Id 'envDiscoveryStatusDynamic' -AutoRefresh -AutoRefreshInterval 5 -Content { $runningRuns = @(Devolutions.CIEM\Get-CIEMAzureDiscoveryRun -Status 'Running') if ($runningRuns.Count -gt 0) { $run = $runningRuns[0] $startedAt = $run.StartedAt $elapsed = '' if ($startedAt) { try { $startTime = [DateTimeOffset]::Parse($startedAt) $duration = [DateTimeOffset]::UtcNow - $startTime if ($duration.TotalMinutes -ge 1) { $elapsed = " — started $([math]::Floor($duration.TotalMinutes)) min ago" } else { $elapsed = " — started $([math]::Floor($duration.TotalSeconds))s ago" } } catch { # Ignore parse errors on started_at } } New-UDCard -Style @{ marginTop = '8px'; marginBottom = '8px'; backgroundColor = '#e3f2fd'; border = '1px solid #90caf9' } -Content { New-UDStack -Direction 'row' -Spacing 2 -AlignItems 'center' -JustifyContent 'space-between' -Content { New-UDStack -Direction 'row' -Spacing 2 -AlignItems 'center' -Content { New-UDProgress -Circular -Size 'small' New-UDTypography -Text "Discovery in progress (Run #$($run.Id), Scope: $($run.Scope))$elapsed" -Variant 'body2' -Style @{ color = '#1565c0' } } New-UDButton -Id 'cancelDiscoveryBtn' -Text 'Cancel' -Variant 'outlined' -Color 'error' -Size 'small' -OnClick { Devolutions.CIEM\Stop-CIEMAzureDiscovery # Also cancel PSU jobs running discovery $discoveryJobs = @(Get-PSUJob -Status 'Running' -Integrated | Where-Object { $_.Script -and $_.Script.Name -like '*Discovery*' }) foreach ($job in $discoveryJobs) { Stop-PSUJob -Job $job -Integrated } Show-UDToast -Message 'Discovery cancelled' -Duration 5000 -BackgroundColor '#ff9800' Sync-UDElement -Id 'envDiscoveryStatusDynamic' } } } } } } # 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 { Devolutions.CIEM\Write-CIEMLog -Message "DYNAMIC CONTENT: envChartDynamic Content block entered" -Severity INFO -Component 'PSU-EnvironmentPage' $loadedModule = Get-Module 'Devolutions.CIEM' | Select-Object -First 1 Devolutions.CIEM\Write-CIEMLog -Message "ENV PAGE: Loaded module version=$($loadedModule.Version), path=$($loadedModule.ModuleBase)" -Severity INFO -Component 'PSU-EnvironmentPage' Devolutions.CIEM\Write-CIEMLog -Message "ENV PAGE: DatabasePath=$script:DatabasePath" -Severity INFO -Component 'PSU-EnvironmentPage' Devolutions.CIEM\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' } Devolutions.CIEM\Write-CIEMLog -Message "DYNAMIC CONTENT: view=$viewMode, orient=$orient" -Severity INFO -Component 'PSU-EnvironmentPage' if ($viewMode -eq 'Identities') { # --- 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' } } } Devolutions.CIEM\Write-CIEMLog -Message "DYNAMIC CONTENT: calling Get-CIEMAzureIdentityHierarchy (mode: $assignmentMode)" -Severity INFO -Component 'PSU-EnvironmentPage' $hierarchy = @(Devolutions.CIEM\Get-CIEMAzureIdentityHierarchy -Mode $assignmentMode) Devolutions.CIEM\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 'Identities' }).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 --- Devolutions.CIEM\Write-CIEMLog -Message "DYNAMIC CONTENT: calling Get-CIEMAzureArmHierarchy (orient: $orient)" -Severity INFO -Component 'PSU-EnvironmentPage' $hierarchy = @(Devolutions.CIEM\Get-CIEMAzureArmHierarchy) Devolutions.CIEM\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 --- # Node type styling table (color, size) $nodeStyles = @{ 'Tenant' = @{ Color = '#42a5f5'; Size = 32 } 'Subscription' = @{ Color = '#66bb6a'; Size = 26 } 'ResourceGroup' = @{ Color = '#ffa726'; Size = 22 } 'Category' = @{ Color = '#ab47bc'; Size = 22 } 'Resource' = @{ Color = '#78909c'; Size = 16 } } # Identity view node types $nodeStyles['IdentityType'] = @{ Color = '#7b1fa2'; Size = 26 } $nodeStyles['Identities'] = @{ Color = '#1565c0'; Size = 22 } $nodeStyles['Role'] = @{ Color = '#00838f'; Size = 20 } $nodeStyles['Scope'] = @{ Color = '#558b2f'; Size = 16 } $defaultStyle = @{ Color = '#bdbdbd'; Size = 18 } $nodeById = @{} foreach ($node in $hierarchy) { $nodeById[$node.NodeId] = $node } $lookup = @{} foreach ($node in $hierarchy) { $style = $nodeStyles[$node.NodeType] ?? $defaultStyle $nodeColor = $style.Color $nodeSize = $style.Size $resType = if ($node.ResourceType) { $node.ResourceType } elseif ($node.Resource) { $node.Resource.Type } else { $null } $entraType = $null if ($viewMode -eq 'Identities') { $identityTypeLabel = $null if ($node.NodeType -eq 'IdentityType') { $identityTypeLabel = $node.Label } elseif ($node.NodeType -eq 'Identities' -and $node.ParentNodeId -and $nodeById.ContainsKey($node.ParentNodeId)) { $identityTypeLabel = $nodeById[$node.ParentNodeId].Label } if ($identityTypeLabel) { $identityTypeName = $identityTypeLabel -replace '\s+\(\d+\)$', '' $entraType = switch ($identityTypeName) { 'User' { 'user' } 'Group' { 'group' } 'ServicePrincipal' { 'servicePrincipal' } 'ManagedIdentity' { 'ManagedIdentity' } default { $null } } } } $nodeIcon = Resolve-CIEMResourceIconDataUri -NodeType ([string]$node.NodeType) -AzureResourceType $resType -EntraType $entraType $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 = [string]$node.Label symbol = "image://$nodeIcon" symbolKeepAspect = $true 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 { $rootIcon = Resolve-CIEMResourceIconDataUri -GraphKind 'AzureResource' @{ name = 'Cloud Environment' symbol = "image://$rootIcon" symbolKeepAspect = $true 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 bgColor = window.getComputedStyle(document.body).backgroundColor; var isDark = false; if (bgColor) { var match = bgColor.match(/\d+/g); if (match) { var r = parseInt(match[0]), g = parseInt(match[1]), b = parseInt(match[2]); isDark = (r * 0.299 + g * 0.587 + b * 0.114) < 128; } } var chart = echarts.init(container, isDark ? 'dark' : null); 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: isDark ? '#e0e0e0' : '#333', 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: isDark ? '#555' : '#bbb', width: 1.5, curveness: 0.5 }, emphasis: { focus: 'descendant', itemStyle: { borderWidth: 2 }, label: { color: isDark ? '#fff' : '#000', fontSize: 17 } }, expandAndCollapse: true, initialTreeDepth: 2, animationDuration: 550, animationDurationUpdate: 750 }] }); window.addEventListener('resize', function() { chart.resize(); }); } tryRender(); })(); "@ Invoke-UDJavaScript -JavaScript $js Devolutions.CIEM\Write-CIEMLog -Message "Environment tree rendered: $resCount resources, $subCount subs, $rgCount RGs" -Severity INFO -Component 'PSU-EnvironmentPage' } catch { $errorMsg = $_.Exception.Message Devolutions.CIEM\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 { Devolutions.CIEM\New-CIEMErrorContent -Text 'Failed to Load Environment' -Details $errorMsg } } } } } -Navigation $Navigation -NavigationLayout permanent } |