GUI/Backups/IntuneAssignmentsViewerUI.ps1
|
param ( [Parameter(Mandatory = $false)] $TreeViewItems ) Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase [xml]$xaml = @" <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Intune Assignments Viewer" Height="740" Width="1775" WindowStartupLocation="CenterScreen" WindowStyle="None" ResizeMode="NoResize" Background="Transparent" AllowsTransparency="True" FontFamily="Segoe UI" FontSize="12" SnapsToDevicePixels="True" UseLayoutRounding="True"> <!-- ==================== Window Resources / Styles ==================== --> <Window.Resources> <Style TargetType="Button"> <Setter Property="Height" Value="24"/> <Setter Property="MinWidth" Value="24"/> <Setter Property="FontSize" Value="12"/> <Setter Property="Padding" Value="8,1,8,1"/> </Style> <Style TargetType="GroupBox"> <Setter Property="Padding" Value="8"/> <Setter Property="FontSize" Value="12"/> </Style> <Style TargetType="DataGrid"> <Setter Property="AutoGenerateColumns" Value="False"/> <Setter Property="CanUserAddRows" Value="False"/> <Setter Property="CanUserDeleteRows" Value="False"/> <Setter Property="CanUserResizeRows" Value="False"/> <Setter Property="HeadersVisibility" Value="Column"/> <Setter Property="GridLinesVisibility" Value="All"/> <Setter Property="IsReadOnly" Value="True"/> <Setter Property="Background" Value="White"/> <Setter Property="BorderBrush" Value="#D2D2D2"/> <Setter Property="BorderThickness" Value="1"/> <Setter Property="RowHeaderWidth" Value="0"/> <Setter Property="SelectionMode" Value="Single"/> <Setter Property="SelectionUnit" Value="FullRow"/> </Style> <Style TargetType="CheckBox"> <Setter Property="FontSize" Value="12"/> </Style> </Window.Resources> <!-- ==================== Outer Rounded Border ==================== --> <Border Margin="0" CornerRadius="10" Background="#F0F0F0" BorderBrush="#D8D8D8" BorderThickness="1" SnapsToDevicePixels="True"> <Grid Margin="0"> <Grid.RowDefinitions> <RowDefinition Height="36"/> <RowDefinition Height="*"/> <RowDefinition Height="44"/> </Grid.RowDefinitions> <!-- ==================== Top Blue Banner ==================== --> <Border Grid.Row="0" Name="TopBanner" Background="#1976C9" CornerRadius="10,10,0,0"> <Grid Margin="10,0,8,0"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="24"/> </Grid.ColumnDefinitions> <TextBlock Grid.Column="0" VerticalAlignment="Center" Foreground="White" FontSize="18" FontWeight="SemiBold" Text="Intune Assignments"/> <Button Grid.Column="1" Name="BtnClose" Content="X" Width="22" Height="22" Padding="0" HorizontalAlignment="Right" VerticalAlignment="Center" Background="#1976C9" Foreground="White" BorderBrush="#4D8DC9"/> </Grid> </Border> <!-- ==================== Main Content (Two-Column Layout) ==================== --> <Grid Grid.Row="1" Grid.RowSpan="2" Margin="10,8,10,8"> <Grid.ColumnDefinitions> <ColumnDefinition Width="400"/> <ColumnDefinition Width="10"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <!-- Left: Backup / Tenant tree --> <GroupBox Grid.Column="0" Header="Intune Tenants" Padding="8"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="6"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <TextBlock Grid.Row="0" Margin="0,0,0,4" Text="Search Product" VerticalAlignment="Center"/> <TextBox Grid.Row="0" Name="TxtProductSearch" Margin="95,0,0,0" Height="24" VerticalAlignment="Center"/> <TreeView Grid.Row="2" Name="TreeBackupFolders" BorderBrush="#D2D2D2" BorderThickness="1" Background="White"/> </Grid> </GroupBox> <!-- Right: Assignment details panels --> <StackPanel Grid.Column="2" Margin="0"> <!-- Selected Backup Folder --> <GroupBox Margin="0,0,0,0" Header="Selected Backup Folder"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="24"/> <RowDefinition Height="6"/> <RowDefinition Height="24"/> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="96"/> <ColumnDefinition Width="*"/> <ColumnDefinition Width="8"/> <ColumnDefinition Width="108"/> </Grid.ColumnDefinitions> <TextBlock Grid.Row="0" Grid.Column="0" Text="Backup Folder" VerticalAlignment="Center"/> <TextBox Grid.Row="0" Grid.Column="1" Name="TxtSelectedFolder" Height="24" VerticalAlignment="Center"/> <TextBlock Grid.Row="2" Grid.Column="0" Text="Folder Path" VerticalAlignment="Center"/> <TextBox Grid.Row="2" Grid.Column="1" Name="TxtSelectedFolderPath" Height="24" VerticalAlignment="Center"/> <Button Grid.Row="2" Grid.Column="3" Name="BtnOpenFolder" Content="Open Folder" IsEnabled="False"/> </Grid> </GroupBox> <!-- Required Assignments: auto-installed on enrolled devices --> <GroupBox Margin="0,0,0,0" Header="Required for enrolled devices"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="125"/> </Grid.RowDefinitions> <DataGrid Grid.Row="0" Name="DgRequiredAssignments"> <DataGrid.Columns> <DataGridTextColumn Header="Group Name" Width="160" Binding="{Binding GroupName}"/> <DataGridTextColumn Header="Mode" Width="80" Binding="{Binding Mode}"/> <DataGridTextColumn Header="Notification" Width="95" Binding="{Binding Notification}"/> <DataGridTextColumn Header="DO Priority" Width="90" Binding="{Binding DOPriority}"/> <DataGridTextColumn Header="Filter Mode" Width="95" Binding="{Binding FilterType}"/> <DataGridTextColumn Header="Filter" Width="160" Binding="{Binding FilterId}"/> <DataGridTextColumn Header="Available Time" Width="110" Binding="{Binding AvailableTime}"/> <DataGridTextColumn Header="Deadline" Width="95" Binding="{Binding Deadline}"/> <DataGridTextColumn Header="Grace Period" Width="95" Binding="{Binding GracePeriod}"/> </DataGrid.Columns> </DataGrid> </Grid> </GroupBox> <!-- Available Assignments: visible in Company Portal but not forced --> <GroupBox Margin="0,0,0,0" Header="Available for enrolled devices"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="125"/> </Grid.RowDefinitions> <DataGrid Grid.Row="0" Name="DgAvailableAssignments"> <DataGrid.Columns> <DataGridTextColumn Header="Group Name" Width="160" Binding="{Binding GroupName}"/> <DataGridTextColumn Header="Mode" Width="80" Binding="{Binding Mode}"/> <DataGridTextColumn Header="Notification" Width="95" Binding="{Binding Notification}"/> <DataGridTextColumn Header="DO Priority" Width="90" Binding="{Binding DOPriority}"/> <DataGridTextColumn Header="Filter Mode" Width="95" Binding="{Binding FilterType}"/> <DataGridTextColumn Header="Filter" Width="160" Binding="{Binding FilterId}"/> <DataGridTextColumn Header="Available Time" Width="110" Binding="{Binding AvailableTime}"/> <DataGridTextColumn Header="Grace Period" Width="95" Binding="{Binding GracePeriod}"/> </DataGrid.Columns> </DataGrid> </Grid> </GroupBox> <!-- Uninstall Assignments: automatically removed from devices --> <GroupBox Margin="0,0,0,0" Header="Uninstall for enrolled devices"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="125"/> </Grid.RowDefinitions> <DataGrid Grid.Row="0" Name="DgUninstallAssignments"> <DataGrid.Columns> <DataGridTextColumn Header="Group Name" Width="160" Binding="{Binding GroupName}"/> <DataGridTextColumn Header="Mode" Width="80" Binding="{Binding Mode}"/> <DataGridTextColumn Header="Notification" Width="95" Binding="{Binding Notification}"/> <DataGridTextColumn Header="DO Priority" Width="90" Binding="{Binding DOPriority}"/> <DataGridTextColumn Header="Filter Mode" Width="95" Binding="{Binding FilterType}"/> <DataGridTextColumn Header="Filter" Width="160" Binding="{Binding FilterId}"/> <DataGridTextColumn Header="Deadline" Width="110" Binding="{Binding Deadline}"/> <DataGridTextColumn Header="Grace Period" Width="95" Binding="{Binding GracePeriod}"/> </DataGrid.Columns> </DataGrid> </Grid> </GroupBox> <!-- Note: Override checkbox for manual assignment sync behaviour --> <GroupBox Margin="0,0,0,0" Header="Note"> <StackPanel> <CheckBox Name="ChkOverrideManualAssignments" IsEnabled="False" Margin="0,0,0,0" Content="Override manual assignment changes made in Intune during the synchronization of the Publisher"/> </StackPanel> </GroupBox> </StackPanel> </Grid> <!-- ==================== Footer Buttons ==================== --> <Grid Grid.Row="2" Margin="10,0,10,8"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="72"/> <ColumnDefinition Width="8"/> <ColumnDefinition Width="72"/> </Grid.ColumnDefinitions> <Button Grid.Column="3" Name="BtnOk" Content="OK" Width="72"/> </Grid> </Grid> </Border> </Window> "@ $reader = New-Object System.Xml.XmlNodeReader $xaml [System.Windows.Window]$formIntuneAssignments = [Windows.Markup.XamlReader]::Load($reader) $xaml.SelectNodes('//*[@Name]') | ForEach-Object { Set-Variable -Name $_.Name -Value $formIntuneAssignments.FindName($_.Name) -Scope Script } function ConvertTo-ProductStateMap { param ( [object[]]$Items ) $map = @{} foreach ($backup in @($Items)) { foreach ($tenant in @($backup.Tenants)) { foreach ($node in @($tenant.ProductList)) { foreach ($product in @($node.Products)) { $key = "$($tenant.Name)|$($node.Name)|$($product.ProductName)" $lines = foreach ($assignment in @($product.Assignments)) { foreach ($row in @($assignment.Assignment)) { "$($product.EnforceIntuneAssignments)|$($assignment.Intent)|$($row.GroupName)|$($row.Mode)|$($row.Notification)|$($row.DOPriority)|$($row.FilterType)|$($row.FilterId)|$($row.AvailableTime)|$($row.Deadline)|$($row.GracePeriod)" } } $map[$key] = ($lines | Sort-Object) -join "`n" } } } } return $map } function Get-BackupChangeInfo { param ( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]] $CurrentItems, [Parameter(Mandatory = $false)] [AllowNull()] [object[]] $PreviousItems ) if (-not $PreviousItems) { return [PSCustomObject]@{ HasChanges = $false Summary = 'Baseline backup' ChangedProducts = [System.Collections.Generic.HashSet[string]]::new() } } $currentMap = ConvertTo-ProductStateMap -Items $CurrentItems $previousMap = ConvertTo-ProductStateMap -Items $PreviousItems $changedProducts = [System.Collections.Generic.HashSet[string]]::new() # Products that are new or have changed assignments foreach ($key in $currentMap.Keys) { if (-not $previousMap.ContainsKey($key) -or $previousMap[$key] -ne $currentMap[$key]) { $null = $changedProducts.Add($key) } } # Products present in previous but removed in current foreach ($key in $previousMap.Keys) { if (-not $currentMap.ContainsKey($key)) { $null = $changedProducts.Add($key) } } $hasChanges = $changedProducts.Count -gt 0 return [PSCustomObject]@{ HasChanges = $hasChanges Summary = if ($hasChanges) { "$($changedProducts.Count) product(s) changed" } else { 'No changes' } ChangedProducts = $changedProducts } } function Set-BackupTreeNodeHighlight { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $TreeViewItem, [Parameter(Mandatory = $true)] $FolderData, [Parameter(Mandatory = $true)] $ChangeInfo, [Parameter(Mandatory = $false)] [AllowNull()] [string] $PreviousBackupName ) $tooltipLines = @( [string]$FolderData.FullName ) if ([string]::IsNullOrWhiteSpace($PreviousBackupName)) { $tooltipLines += 'Baseline backup' } else { $tooltipLines += "Compared to: $PreviousBackupName" $tooltipLines += "Changes: $($ChangeInfo.Summary)" } $TreeViewItem.ToolTip = $tooltipLines -join [Environment]::NewLine if (-not $ChangeInfo.HasChanges) { return } $TreeViewItem.Background = [System.Windows.Media.Brushes]::LightGoldenrodYellow #$TreeViewItem.Foreground = [System.Windows.Media.Brushes]::DarkSlateGray #$TreeViewItem.FontWeight = [System.Windows.FontWeights]::Bold } function Update-TreeView { param ( [Parameter(Mandatory = $true)] $ObjectData, [string] $HeaderRoot = "Backups" ) # Clear existing items $TreeBackupFolders.Items.Clear() # Create and Add the Root Node for Backups $TreeViewItem_Parent = New-Object System.Windows.Controls.TreeViewItem $TreeViewItem_Parent.Header = $HeaderRoot $previousBackup = $null foreach ($Object in $ObjectData) { # Create a TreeViewItem for the Backup Folder $TreeViewItem_Backup = New-Object System.Windows.Controls.TreeViewItem $TreeViewItem_Backup.Header = $Object.Name $TreeViewItem_Backup.Tag = @{ Level = "BackupFolder" Name = $Object.Name FullName = $Object.FullName Backup = $Object } # Check for Changes $ChangeInfo = Get-BackupChangeInfo -CurrentItems @($Object) -PreviousItems $previousBackup Set-BackupTreeNodeHighlight -TreeViewItem $TreeViewItem_Backup -FolderData $Object -ChangeInfo $ChangeInfo -PreviousBackupName $previousBackup.Name foreach ($Tenant in @($Object.Tenants)) { if (-not $Tenant) { continue } $TreeViewItem_Tenant = New-Object System.Windows.Controls.TreeViewItem $TreeViewItem_Tenant.Header = if ($Tenant.Name) { "$($Tenant.Name)" } else { "Tenant" } $TreeViewItem_Tenant.Tag = @{ Level = "Tenant" Name = $Object.Name FullName = $Object.FullName Tenant = $Tenant } foreach ($ProductNode in @($Tenant.ProductList)) { if (-not $ProductNode) { continue } $TreeViewItem_ProductNode = New-Object System.Windows.Controls.TreeViewItem $TreeViewItem_ProductNode.Header = "$($ProductNode.Name) - $($ProductNode.Products.Count)" $TreeViewItem_ProductNode.Tag = @{ Level = "ProductNode" Name = $Object.Name FullName = $Object.FullName Product = $ProductNode } $productNodeHasChanges = $false foreach ($Product in @($ProductNode.Products)) { if (-not $Product) { continue } $TreeViewItem_Product = New-Object System.Windows.Controls.TreeViewItem $TreeViewItem_Product.Header = $Product.ProductName $TreeViewItem_Product.Tag = @{ Level = "Product" Name = $Object.Name FullName = $Object.FullName ProductDetails = $Product } # Highlight products whose assignments changed since the previous backup $productKey = "$($Tenant.Name)|$($ProductNode.Name)|$($Product.ProductName)" if ($ChangeInfo.ChangedProducts.Count -gt 0 -and $ChangeInfo.ChangedProducts.Contains($productKey)) { $TreeViewItem_Product.Background = [System.Windows.Media.Brushes]::LightGoldenrodYellow $TreeViewItem_Product.ToolTip = "Changed since: $($previousBackup.Name)" $productNodeHasChanges = $true } $TreeViewItem_ProductNode.Items.Add($TreeViewItem_Product) } if ($productNodeHasChanges) { $TreeViewItem_ProductNode.Background = [System.Windows.Media.Brushes]::LightGoldenrodYellow $TreeViewItem_ProductNode.ToolTip = "Contains changes since: $($previousBackup.Name)" } $TreeViewItem_Tenant.Items.Add($TreeViewItem_ProductNode) } $TreeViewItem_Backup.Items.Add($TreeViewItem_Tenant) } # Add the Backup Folder node to the Root $TreeViewItem_Parent.Items.Add($TreeViewItem_Backup) # Update the previous backup variable for the next iteration $previousBackup = $Object } $TreeBackupFolders.Items.Add($TreeViewItem_Parent) $TreeViewItem_Parent.IsExpanded = $true } function Invoke-ProductSearchFilter { [CmdletBinding()] param () $search = $TxtProductSearch.Text.Trim() $hasSearch = -not [string]::IsNullOrWhiteSpace($search) $rootNode = $TreeBackupFolders.Items[0] if (-not $rootNode) { return } foreach ($backupNode in $rootNode.Items) { $backupHasMatch = $false foreach ($tenantNode in $backupNode.Items) { $tenantHasMatch = $false foreach ($productNodeItem in $tenantNode.Items) { $nodeHasMatch = $false foreach ($productItem in $productNodeItem.Items) { if ($hasSearch) { $isMatch = ([string]$productItem.Header).IndexOf($search, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 $productItem.Visibility = if ($isMatch) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } if ($isMatch) { $nodeHasMatch = $true } } else { $productItem.Visibility = [System.Windows.Visibility]::Visible $nodeHasMatch = $true } } $productNodeItem.Visibility = if ($nodeHasMatch) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } if ($nodeHasMatch) { $tenantHasMatch = $true } } $tenantNode.Visibility = if ($tenantHasMatch) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } if ($tenantHasMatch) { $backupHasMatch = $true } } $backupNode.Visibility = if ($backupHasMatch) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } } } function Set-SelectedProductDetails { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $ProductData ) foreach ($Assignment in $ProductData.Assignments) { # Convert the Assignment hashtable to a custom object for easier binding to the DataGrid [PSCustomObject]$PSObjectAssignment = $Assignment.Assignment | ConvertTo-Json | ConvertFrom-Json # Add to the DataGrid switch ($Assignment.Intent) { "Required" { # Leaving these here for the future. Will reset the column widths initialls before setting the items #$DgRequiredAssignments.Columns | ForEach-Object { $_.Width = [System.Windows.Controls.DataGridLength]::SizeToHeader } $DgRequiredAssignments.Items.Add($PSObjectAssignment) # Resize the columns to fit the content $DgRequiredAssignments.Columns | ForEach-Object { $_.Width = [System.Windows.Controls.DataGridLength]::Auto } } "Available" { $DgAvailableAssignments.Columns | ForEach-Object { $_.Width = [System.Windows.Controls.DataGridLength]::SizeToHeader } $DgAvailableAssignments.Items.Add($PSObjectAssignment) $DgAvailableAssignments.Columns | ForEach-Object { $_.Width = [System.Windows.Controls.DataGridLength]::Auto } } "Uninstall" { $DgUninstallAssignments.Columns | ForEach-Object { $_.Width = [System.Windows.Controls.DataGridLength]::SizeToHeader } $DgUninstallAssignments.Items.Add($PSObjectAssignment) $DgUninstallAssignments.Columns | ForEach-Object { $_.Width = [System.Windows.Controls.DataGridLength]::Auto } } } } # Set the override checkbox state and enable it if there are any assignments $ChkOverrideManualAssignments.IsChecked = $ProductData.EnforceIntuneAssignments -eq $true } function Clear-ProductDetails { $DgRequiredAssignments.Items.Clear() $DgAvailableAssignments.Items.Clear() $DgUninstallAssignments.Items.Clear() $ChkOverrideManualAssignments.IsChecked = $false } function Set-SelectedFolder { param ( [Parameter(Mandatory = $true)] $SelectedNode ) Clear-ProductDetails $TxtSelectedFolder.Text = "$($SelectedNode.Tag.Name)" $TxtSelectedFolderPath.Text = "$($SelectedNode.Tag.FullName)" $BtnOpenFolder.IsEnabled = $true } #### Form Load ##### $formIntuneAssignments.Add_Loaded({ $script:TreeViewItemsSource = @($TreeViewItems) Update-TreeView -ObjectData $script:TreeViewItemsSource }) $TxtProductSearch.Add_TextChanged({ Invoke-ProductSearchFilter }) $TreeBackupFolders.Add_SelectedItemChanged({ $SelectedNode = $TreeBackupFolders.SelectedItem if (-not $SelectedNode -or -not $SelectedNode.Tag) { return } Clear-ProductDetails Set-SelectedFolder -SelectedNode $SelectedNode if ($SelectedNode.Tag.Level -eq 'Product') { Set-SelectedProductDetails -ProductData $SelectedNode.Tag.ProductDetails } }) $BtnOpenFolder.Add_Click({ if (-not [string]::IsNullOrWhiteSpace($TxtSelectedFolderPath.Text) -and (Test-Path -LiteralPath $TxtSelectedFolderPath.Text)) { try { # Open the backup folder in File Explorer with the file selected #Start-Process -FilePath "explorer.exe" -ArgumentList "$($TxtSelectedFolderPath.Text)" Start-Process -FilePath "explorer.exe" -ArgumentList "/select,`"$($TxtSelectedFolderPath.Text)\Settings.xml`"" Write-Host -ForegroundColor DarkGray "[$(Get-Date -format G)] Successfully Opened: [$($TxtSelectedFolderPath.Text)]" } catch { Write-Host "Failed to open the backup folder in File Explorer" Write-Error "$($_.Exception.Message)" } } }) $BtnClose.Add_Click({ $formIntuneAssignments.Close() }) $BtnOk.Add_Click({ $formIntuneAssignments.Close() }) $TopBanner.Add_MouseLeftButtonDown({ $formIntuneAssignments.DragMove() }) $null = $formIntuneAssignments.ShowDialog() |