Modules/M365DSCReport.psm1
<#
.Description This function creates a new HTML document from the specified exported configuration .Functionality Internal, Hidden #> function New-M365DSCConfigurationToHTML { [CmdletBinding()] [OutputType([System.String])] Param( [Parameter()] [System.String] $ConfigurationPath, [Parameter()] [Array] $ParsedContent, [Parameter()] [System.String] $OutputPath, [Parameter()] [Switch] $SortProperties ) if ([System.String]::IsNullOrEmpty($ParsedContent)) { $TemplateFile = Get-Item $ConfigurationPath $fileContent = Get-Content $ConfigurationPath -Raw $startPosition = $fileContent.IndexOf(" -ModuleVersion") $endPosition = $fileContent.IndexOf("`r", $startPosition) $fileContent = $fileContent.Remove($startPosition, $endPosition - $startPosition) $ParsedContent = ConvertTo-DSCObject -Content $fileContent $TemplateName = $TemplateFile.Name.Split('.')[0] } else { $TemplateName = "Configuration Report" } $fullHTML = "<h1>" + $TemplateName + "</h1>" $fullHTML += "<div style='width:100%;text-align:center;'>" $fullHTML += "<h2>Template Details</h2>" foreach ($resource in $parsedContent) { $partHTML = "<div width='100%' style='text-align:center;'><table width='80%' style='margin-left:auto; margin-right:auto;'>" $partHTML += "<tr><th rowspan='" + ($resource.Keys.Count) + "' width='20%'>" $partHTML += "<img src='" + (Get-IconPath -ResourceName $resource.ResourceName) + "' />" $partHTML += "</th>" $partHTML += "<th colspan='2' style='background-color:silver;text-align:center;' width='80%'>" $partHTML += "<strong>" + $resource.ResourceName + "</strong>" $partHTML += "</th></tr>" if ($SortProperties) { $properties = $resource.Keys | Sort-Object } else { $properties = $resource.Keys } foreach ($property in $properties) { if ($property -ne "ResourceName" -and $property -ne "Credential") { $partHTML += "<tr><td width='40%' style='padding:5px;text-align:right;border:1px solid black;'><strong>" + $property + "</strong></td>" $value = "`$Null" if ($null -ne $resource.$property) { if ($resource.$property.GetType().Name -eq 'Object[]') { if ($resource.$property -and $resource.$property[0].GetType().Name -eq 'Hashtable') { $value = "" foreach ($entry in $resource.$property) { foreach ($key in $entry.Keys) { $value += "<li>$key = $($entry.$key)</li>" } $value += "<hr />" } } else { $temp = $resource.$property -join ',' [array]$components = $temp.Split(',') if ($components.Length -gt 0 -and -not [System.String]::IsNullOrEmpty($temp)) { $Value = "<ul>" foreach ($comp in $components) { $value += "<li>$comp</li>" } $value += "</ul>" } } } else { if (-not [System.String]::IsNullOrEmpty($resource.$property)) { $value = ($resource.$property).ToString() } } } $partHTML += "<td width='40%' style='padding:5px;border:1px solid black;'>" + $value + "</td></tr>" } } $partHTML += "</table></div><br />" $fullHtml += $partHTML } if (-not [System.String]::IsNullOrEmpty($OutputPath)) { $fullHtml | Out-File $OutputPath } return $fullHTML } <# .Description This function gets the URL to the logo of the workload of the specified resource .Functionality Internal, Hidden #> function Get-IconPath { [CmdletBinding()] [OutputType([System.String])] param( [Parameter(Mandatory = $true)] [System.String] $ResourceName ) if ($ResourceName.StartsWith("AAD")) { return "http://microsoft365dsc.com/Images/AzureAD.jpg" } elseif ($ResourceName.StartsWith("EXO")) { return "http://microsoft365dsc.com/Images/Exchange.jpg" } elseif ($ResourceName.StartsWith("O365")) { return "http://microsoft365dsc.com/Images/Office365.jpg" } elseif ($ResourceName.StartsWith("OD")) { return "http://microsoft365dsc.com/Images/OneDrive.jpg" } elseif ($ResourceName.StartsWith("PP")) { return "http://microsoft365dsc.com/Images/PowerApps.jpg" } elseif ($ResourceName.StartsWith("SC")) { return "http://microsoft365dsc.com/Images/SecurityAndCompliance.png" } elseif ($ResourceName.StartsWith("SPO")) { return "http://microsoft365dsc.com/Images/SharePoint.jpg" } elseif ($ResourceName.StartsWith("Teams")) { return "http://microsoft365dsc.com/Images/Teams.jpg" } return $null } <# .Description This function creates a new Excel document from the specified exported configuration .Functionality Internal, Hidden #> function New-M365DSCConfigurationToExcel { [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [System.String] $ConfigurationPath, [Parameter(Mandatory = $true)] [System.String] $OutputPath ) $excel = New-Object -ComObject excel.application $excel.visible = $True $workbook = $excel.Workbooks.Add() $report = $workbook.Worksheets.Item(1) $report.Name = 'Report' $report.Cells.Item(1, 1) = "Component Name" $report.Cells.Item(1, 1).Font.Size = 18 $report.Cells.Item(1, 1).Font.Bold = $True $report.Cells.Item(1, 2) = "Property" $report.Cells.Item(1, 2).Font.Size = 18 $report.Cells.Item(1, 2).Font.Bold = $True $report.Cells.Item(1, 3) = "Value" $report.Cells.Item(1, 3).Font.Size = 18 $report.Cells.Item(1, 3).Font.Bold = $True $report.Range("A1:C1").Borders.Weight = -4138 $row = 2 $fileContent = Get-Content $ConfigurationPath -Raw $startPosition = $fileContent.IndexOf(" -ModuleVersion") $endPosition = $fileContent.IndexOf("`r", $startPosition) $fileContent = $fileContent.Remove($startPosition, $endPosition - $startPosition) $ParsedContent = ConvertTo-DSCObject -Content $fileContent foreach ($resource in $parsedContent) { $beginRow = $row foreach ($property in $resource.Keys) { if ($property -ne "ResourceName" -and $property -ne "Credential") { $report.Cells.Item($row, 1) = $resource.ResourceName $report.Cells.Item($row, 2) = $property try { if ([System.String]::IsNullOrEmpty($resource.$property)) { $report.Cells.Item($row, 3) = "`$Null" } else { if ($resource.$property.GetType().Name -eq 'Object[]') { $value = $resource.$property -join ',' $report.Cells.Item($row, 3) = $value } else { $value = ($resource.$property).ToString().Replace("$", "") $value = $value.Replace("@", "") $value = $value.Replace("(", "") $value = $value.Replace(")", "") $report.Cells.Item($row, 3) = $value } } $report.Cells.Item($row, 3).HorizontalAlignment = -4131 } catch { Write-Verbose -Message $_ Add-M365DSCEvent -Message $_ -EntryType 'Error' ` -EventID 1 -Source $($MyInvocation.MyCommand.Source) } if ($property -in @("Identity", "Name", "IsSingleInstance", "DisplayName")) { $OriginPropertyName = $report.Cells.Item($beginRow, 2).Text $OriginPropertyValue = $report.Cells.Item($beginRow, 3).Text $CurrentPropertyName = $report.Cells.Item($row, 2).Text $CurrentPropertyValue = $report.Cells.Item($row, 3).Text $report.Cells.Item($beginRow, 2) = $CurrentPropertyName $report.Cells.Item($beginRow, 3) = $CurrentPropertyValue $report.Cells.Item($row, 2) = $OriginPropertyName $report.Cells.Item($row, 3) = $OriginPropertyValue $report.Cells($beginRow, 1).Font.ColorIndex = 10 $report.Cells($beginRow, 2).Font.ColorIndex = 10 $report.Cells($beginRow, 3).Font.ColorIndex = 10 $report.Cells($beginRow, 1).Font.Bold = $true $report.Cells($beginRow, 2).Font.Bold = $true $report.Cells($beginRow, 3).Font.Bold = $true } $row++ } } $rangeValue = "A$beginRow" + ":" + "C$row" $report.Range($rangeValue).Borders[8].Weight = -4138 } $usedRange = $report.UsedRange $usedRange.EntireColumn.AutoFit() | Out-Null $workbook.SaveAs($OutputPath) #$excel.Quit() } <# .Description This function creates a report from the specified exported configuration, either in HTML or Excel format .Parameter Type The type of report that should be created: Excel or HTML. .Parameter ConfigurationPath The path to the exported DSC configuration that the report should be created for. .Parameter OutputPath The output path of the report. .Example New-M365DSCReportFromConfiguration -Type 'HTML' -ConfigurationPath 'C:\DSC\' -OutputPath 'C:\Dsc\M365Report.html' .Example New-M365DSCReportFromConfiguration -Type 'Excel' -ConfigurationPath 'C:\DSC\' -OutputPath 'C:\Dsc\M365Report.xlsx' .Functionality Public #> function New-M365DSCReportFromConfiguration { [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [ValidateSet('Excel', 'HTML')] [System.String] $Type, [Parameter(Mandatory = $true)] [System.String] $ConfigurationPath, [Parameter(Mandatory = $true)] [System.String] $OutputPath ) #Ensure the proper dependencies are installed in the current environment. Confirm-M365DSCDependencies #region Telemetry $data = [System.Collections.Generic.Dictionary[[String], [String]]]::new() $data.Add("Event", "Report") $data.Add("Type", $Type) Add-M365DSCTelemetryEvent -Data $data #endregion switch ($Type) { "Excel" { New-M365DSCConfigurationToExcel -ConfigurationPath $ConfigurationPath -OutputPath $OutputPath } "HTML" { New-M365DSCConfigurationToHTML -ConfigurationPath $ConfigurationPath -OutputPath $OutputPath } } } <# .Description This function compares two provided DSC configuration to determine the delta .Functionality Internal, Hidden #> function Compare-M365DSCConfigurations { [CmdletBinding()] [OutputType([System.Array])] param ( [Parameter()] [System.String] $Source, [Parameter()] [System.String] $Destination, [Parameter()] [System.Boolean] $CaptureTelemetry = $true, [Parameter()] [Array] $SourceObject, [Parameter()] [Array] $DestinationObject ) if ($CaptureTelemetry) { #Ensure the proper dependencies are installed in the current environment. Confirm-M365DSCDependencies #region Telemetry $data = [System.Collections.Generic.Dictionary[[String], [String]]]::new() $data.Add("Event", "Compare") Add-M365DSCTelemetryEvent -Data $data #endregion } [Array] $Delta = @() if (-not $SourceObject) { $fileContent = Get-Content $Source -Raw $startPosition = $fileContent.IndexOf(" -ModuleVersion") $endPosition = $fileContent.IndexOf("`r", $startPosition) $fileContent = $fileContent.Remove($startPosition, $endPosition - $startPosition) [Array] $SourceObject = ConvertTo-DSCObject -Content $fileContent } if (-not $DestinationObject) { $fileContent = Get-Content $Destination -Raw $startPosition = $fileContent.IndexOf(" -ModuleVersion") $endPosition = $fileContent.IndexOf("`r", $startPosition) $fileContent = $fileContent.Remove($startPosition, $endPosition - $startPosition) [Array] $DestinationObject = ConvertTo-DSCObject -Content $FileContent } # Loop through all items in the source array $i = 1 foreach ($sourceResource in $SourceObject) { try { [array]$key = Get-M365DSCResourceKey -Resource $sourceResource Write-Progress -Activity "Scanning Source $Source...[$i/$($SourceObject.Count)]" -PercentComplete ($i / ($SourceObject.Count) * 100) [array]$destinationResource = $DestinationObject | Where-Object -FilterScript { $_.ResourceName -eq $sourceResource.ResourceName -and $_.($key[0]) -eq $sourceResource.($key[0]) } $keyname = $key[0..1] -join '\' $SourceKeyValue = $sourceResource.($key[0]) # Filter on the second key if ($key.Count -gt 1) { [array]$destinationResource = $destinationResource | Where-Object -FilterScript { $_.ResourceName -eq $sourceResource.ResourceName -and $_.($key[1]) -eq $sourceResource.($key[1]) } $SourceKeyValue = $sourceResource.($key[0]), $sourceResource.($key[1]) -join '\' } if ($null -eq $destinationResource) { $drift = @{ ResourceName = $sourceResource.ResourceName Key = $keyName KeyValue = $SourceKeyValue Properties = @(@{ ParameterName = '_IsInConfiguration_' ValueInSource = 'Present' ValueInDestination = 'Absent' }) } $Delta += , $drift $drift = $null } else { [System.Collections.Hashtable]$destinationResource = $destinationResource[0] # The resource instance exists in both the source and the destination. Compare each property; foreach ($propertyName in $sourceResource.Keys) { if ($propertyName -notin @("ResourceName", "Credential", "CertificatePath", "CertificatePassword", "TenantId", "ApplicationId", "CertificateThumbprint", "ApplicationSecret")) { # Needs to be a separate nested if statement otherwise the ReferenceObject an be null and it will error out; if ([System.String]::IsNullOrEmpty($destinationResource.$propertyName) -or (-not [System.String]::IsNullOrEmpty($propertyName) -and $null -ne (Compare-Object -ReferenceObject ($sourceResource.$propertyName)` -DifferenceObject ($destinationResource.$propertyName)))) { if ($null -eq $drift) { $drift = @{ ResourceName = $sourceResource.ResourceName Key = $keyname KeyValue = $SourceKeyValue Properties = @(@{ ParameterName = $propertyName ValueInSource = $sourceResource.$propertyName ValueInDestination = $destinationResource.$propertyName }) } if ($destinationResource.Contains("_metadata_$($propertyName)")) { $Metadata = $destinationResource."_metadata_$($propertyName)" $Level = $Metadata.Split('|')[0].Replace("### ", "") $Information = $Metadata.Split('|')[1] $drift.Properties[0].Add("_Metadata_Level", $Level) $drift.Properties[0].Add("_Metadata_Info", $Information) } } else { $newDrift = @{ ParameterName = $propertyName ValueInSource = $sourceResource.$propertyName ValueInDestination = $destinationResource.$propertyName } if ($destinationResource.Contains("_metadata_$($propertyName)")) { $Metadata = $destinationResource."_metadata_$($propertyName)" $Level = $Metadata.Split('|')[0].Replace("### ", "") $Information = $Metadata.Split('|')[1] $newDrift.Add("_Metadata_Level", $Level) $newDrift.Add("_Metadata_Info", $Information) } $drift.Properties += $newDrift } } } } # Do the scan the other way around because there's a chance that the property, if null, wasn't part of the source # object. By scanning against the destination we will catch properties that are not null on the source but not null in destination; foreach ($propertyName in $destinationResource.Keys) { if ($propertyName -notin @("ResourceName", "Credential", "CertificatePath", "CertificatePassword", "TenantId", "ApplicationId", "CertificateThumbprint", "ApplicationSecret")) { if (-not [System.String]::IsNullOrEmpty($propertyName) -and -not $sourceResource.Contains($propertyName)) { if ($null -eq $drift) { $drift = @{ ResourceName = $sourceResource.ResourceName Key = $keyName KeyValue = $SourceKeyValue Properties = @(@{ ParameterName = $propertyName ValueInSource = $null ValueInDestination = $destinationResource.$propertyName }) } } else { $drift.Properties += @{ ParameterName = $propertyName ValueInSource = $null ValueInDestination = $destinationResource.$propertyName } } } } } if ($null -ne $drift) { $Delta += , $drift $drift = $null } } } catch { Write-Host "Error: $($sourceResource.ResourceName)" } $i++ } Write-Progress -Activity "Scanning Source..." -Completed # Loop through all items in the destination array $i = 1 foreach ($destinationResource in $DestinationObject) { [System.Collections.HashTable]$currentDestinationResource = ([array]$destinationResource)[0] $key = Get-M365DSCResourceKey -Resource $currentDestinationResource Write-Progress -Activity "Scanning Destination $Destination...[$i/$($DestinationObject.Count)]" -PercentComplete ($i / ($DestinationObject.Count) * 100) $sourceResource = $SourceObject | Where-Object -FilterScript { $_.ResourceName -eq $currentDestinationResource.ResourceName -and $_.($key[0]) -eq $currentDestinationResource.($key[0]) } $currentDestinationKeyValue = $currentDestinationResource.($key[0]) # Filter on the second key if ($key.Count -gt 1) { [array]$sourceResource = $sourceResource | Where-Object -FilterScript { $_.ResourceName -eq $currentDestinationResource.ResourceName -and $_.($key[1]) -eq $currentDestinationResource.($key[1]) } $currentDestinationKeyValue = $currentDestinationResource.($key[0]), $currentDestinationResource.($key[1]) -join '\' } if ($null -eq $sourceResource) { $drift = @{ ResourceName = $currentDestinationResource.ResourceName Key = $keyName KeyValue = $currentDestinationKeyValue Properties = @(@{ ParameterName = 'Ensure' ValueInSource = 'Absent' ValueInDestination = 'Present' }) } $Delta += , $drift $drift = $null } $i++ } Write-Progress -Activity "Scanning Destination..." -Completed return $Delta } <# .Description This function gets the key parameter for the specified resource .Functionality Internal, Hidden #> function Get-M365DSCResourceKey { [CmdletBinding()] [OutputType([System.Object[]])] param( [Parameter(Mandatory = $true)] [System.Collections.Hashtable] $Resource ) if ($Resource.Contains("IsSingleInstance")) { return @("IsSingleInstance") } elseif ($Resource.Contains("DisplayName")) { if ($Resource.ResourceName -eq 'AADMSGroup' -and -not [System.String]::IsNullOrEmpty($Resource.Id)) { return @("Id") } if ($Resource.ResourceName -eq 'TeamsChannel' -and -not [System.String]::IsNullOrEmpty($Resource.TeamName)) { # Teams Channel displaynames are not tenant-unique (e.g. "General" is almost in every team), but should be unique per team return @('TeamName', 'DisplayName') } if ($Resource.ResourceName -eq 'TeamsTeam' -and -not [System.String]::IsNullOrEmpty($Resource.MailNickName)) { # Teams names are not unique return @('MailNickName', 'DisplayName') } return @("DisplayName") } elseif ($Resource.Contains("Identity")) { return @("Identity") } elseif ($Resource.Contains("Name")) { return @("Name") } elseif ($Resource.Contains("Url")) { return @("Url") } elseif ($Resource.Contains("Organization")) { return @("Organization") } elseif ($Resource.Contains("CDNType")) { return @("CDNType") } elseif ($Resource.Contains("Action") -and $Resource.ResourceName -eq 'SCComplianceSearchAction') { return @("SearchName", "Action") } elseif ($Resource.Contains("Workload") -and $Resource.ResourceName -eq 'SCAuditConfigurationPolicy') { return @("Workload") } elseif ($Resource.Contains("Title") -and $Resource.ResourceName -eq 'SPOSiteDesign') { return @("Title") } elseif ($Resource.Contains("SiteDesignTitle")) { return @("SiteDesignTitle") } elseif ($Resource.Contains("Key") -and $Resource.ResourceName -eq 'SPOStorageEntity') { return @("Key") } elseif ($Resource.Contains("Usage")) { return @("Usage") } elseif ($Resource.Contains("OrgWideAccount")) { return @("OrgWideAccount") } } <# .Description This function creates a delta HTML report between two provided exported DSC configurations .Parameter Source The source DSC configuration to compare from. .Parameter Destination The destination DSC configuration to compare with. .Parameter OutputPath The output path of the delta report. .Parameter DriftOnly Specifies that only difference should be in the report. .Parameter IsBlueprintAssessment Specifies that the report is a comparison with a Blueprint. .Parameter HeaderFilePath Specifies that file that contains a custom header for the report. .Parameter Delta An array with difference, already compiled from another source. .Example New-M365DSCDeltaReport -Source 'C:\DSC\Source.ps1' -Destination 'C:\DSC\Destination.ps1' -OutputPath 'C:\Dsc\DeltaReport.html' .Example New-M365DSCDeltaReport -Source 'C:\DSC\Source.ps1' -Destination 'C:\DSC\Destination.ps1' -OutputPath 'C:\Dsc\DeltaReport.html' -DriftOnly $true .Functionality Public #> function New-M365DSCDeltaReport { [CmdletBinding()] param( [Parameter()] [System.String] $Source, [Parameter()] [System.String] $Destination, [Parameter()] [System.String] $OutputPath, [Parameter()] [System.Boolean] $DriftOnly = $false, [Parameter()] [System.Boolean] $IsBlueprintAssessment = $false, [Parameter()] [System.String] $HeaderFilePath, [Parameter()] [Array] $Delta ) #Ensure the proper dependencies are installed in the current environment. Confirm-M365DSCDependencies #region Telemetry $data = [System.Collections.Generic.Dictionary[[String], [String]]]::new() $data.Add("Event", "DeltaReport") Add-M365DSCTelemetryEvent -Data $data #endregion Write-Verbose -Message 'Obtaining Delta between the source and destination configurations' if (-not $Delta) { $Delta = Compare-M365DSCConfigurations -Source $Source -Destination $Destination -CaptureTelemetry $false } $reportSB = [System.Text.StringBuilder]::new() #region Custom Header if (-not [System.String]::IsNullOrEmpty($HeaderFilePath)) { try { $headerContent = Get-Content $HeaderFilePath [void]$reportSB.AppendLine($headerContent) } catch { Write-Verbose -Message $_ Add-M365DSCEvent -Message $_ -EntryType 'Error' ` -EventID 1 -Source $($MyInvocation.MyCommand.Source) } } #endregion $ReportTitle = "Microsoft365DSC - Delta Report" if ($IsBlueprintAssessment) { $ReportTitle = "Microsoft365DSC - Blueprint Assessment Report" [void]$reportSB.AppendLine("<h1>Blueprint Assessment Report</h1>") } else { [void]$reportSB.AppendLine("<h1>Delta Report</h1>") [void]$reportSB.AppendLine("<p><strong>Comparing </strong>$Source <strong>to</strong> $Destination</p>") } [void]$reportSB.AppendLine("<html><head><meta charset='utf-8'><title>$ReportTitle</title></head><body>") [void]$reportSB.AppendLine("<div style='width:100%;text-align:center;'>") [void]$reportSB.AppendLine("<img src='http://Microsoft365DSC.com/Images/Promo.png' alt='Microsoft365DSC Slogan' width='500' />") [void]$ReportSB.AppendLine("</div>") [array]$resourcesMissingInSource = $Delta | Where-Object -FilterScript { $_.Properties.ParameterName -eq '_IsInConfiguration_' -and ` $_.Properties.ValueInSource -eq 'Absent' } [array]$resourcesMissingInDestination = $Delta | Where-Object -FilterScript { $_.Properties.ParameterName -eq '_IsInConfiguration_' -and ` $_.Properties.ValueInDestination -eq 'Absent' } [array]$resourcesInDrift = $Delta | Where-Object -FilterScript { $_.Properties.ParameterName -ne '_IsInConfiguration_' } if ($resourcesMissingInSource.Count -eq 0 -and $resourcesMissingInDestination.Count -eq 0 -and ` $resourcesInDrift.Count -eq 0) { [void]$reportSB.AppendLine("<p><strong>No discrepancies have been found!</strong></p>") } elseif (-not $DriftOnly) { [void]$reportSB.AppendLine("<h2>Table of Contents</h2>") [void]$reportSB.AppendLine("<ul>") if ($resourcesMissingInSource.Count -gt 0) { [void]$reportSB.AppendLine("<li><a href='#Source'>Resources Missing in the Source</a>") [void]$reportSB.AppendLine(" <strong>(</strong>$($resourcesMissingInSource.Count)<strong>)</strong></li>") } if ($resourcesMissingInDestination.Count -gt 0) { [void]$reportSB.AppendLine("<li><a href='#Destination'>Resources Missing in the Destination</a>") [void]$reportSB.AppendLine(" <strong>(</strong>$($resourcesMissingInDestination.Count)<strong>)</strong></li>") } if ($resourcesInDrift.Count -gt 0) { [void]$reportSB.AppendLine("<li><a href='#Drift'>Resources Configured Differently</a>") [void]$reportSB.AppendLine(" <strong>(</strong>$($resourcesInDrift.Count)<strong>)</strong></li>") } [void]$reportSB.AppendLine("</ul>") } if ($resourcesMissingInSource.Count -gt 0 -and -not $DriftOnly) { [void]$reportSB.AppendLine("<br /><hr /><br />") [void]$reportSB.AppendLine("<a id='Source'></a><h2>Resources that are Missing in the Source</h2>") foreach ($resource in $resourcesMissingInSource) { [void]$reportSB.AppendLine("<table width='100%' cellspacing='0' cellpadding='5'>") [void]$reportSB.AppendLine("<tr>") [void]$reportSB.Append("<th style='width:25%;text-align:center;vertical-align:middle;border-left:1px solid black;") [void]$reportSB.Append("border-top:1px solid black;border-bottom:1px solid black;'>") $iconPath = Get-IconPath -ResourceName $resource.ResourceName [void]$reportSB.AppendLine("<img src='$iconPath' />") [void]$reportSB.AppendLine("</th>"); [void]$reportSB.AppendLine("<th style='border:1px solid black;text-align:center;'>") [void]$reportSB.AppendLine("<h3>$($resource.ResourceName) - $($resource.Key) = $($resource.KeyValue)</h3>") [void]$reportSB.AppendLine("</th>") [void]$reportSB.AppendLine("</tr>") [void]$reportSB.AppendLine("</table>") } } if ($resourcesMissingInDestination.Count -gt 0 -and -not $DriftOnly) { [void]$reportSB.AppendLine("<br /><hr /><br />") [void]$reportSB.AppendLine("<a id='Destination'></a><h2>Resources that are Missing in the Destination</h2>") foreach ($resource in $resourcesMissingInDestination) { [void]$reportSB.AppendLine("<table width='100%' cellspacing='0' cellpadding='5'>") [void]$reportSB.AppendLine("<tr>") [void]$reportSB.Append("<th style='width:25%;text-align:center;vertical-align:middle;border-left:1px solid black;") [void]$reportSB.Append("border-top:1px solid black;border-bottom:1px solid black;'>") $iconPath = Get-IconPath -ResourceName $resource.ResourceName [void]$reportSB.AppendLine("<img src='$iconPath' />") [void]$reportSB.AppendLine("</th>"); [void]$reportSB.AppendLine("<th style='border:1px solid black;text-align:center;'>") [void]$reportSB.AppendLine("<h3>$($resource.ResourceName) - $($resource.Key) = $($resource.KeyValue)</h3>") [void]$reportSB.AppendLine("</th>") [void]$reportSB.AppendLine("</tr>") [void]$reportSB.AppendLine("</table>") } } if ($resourcesInDrift.Count -gt 0) { [void]$reportSB.AppendLine("<br /><hr /><br />") [void]$reportSB.AppendLine("<a id='Drift'></a><h2>Resources that are Configured Differently</h2>") foreach ($resource in $resourcesInDrift) { [void]$reportSB.AppendLine("<table width='100%' cellspacing='0' cellpadding='5'>") [void]$reportSB.AppendLine("<tr>") [void]$reportSB.Append("<th style='width:25%;text-align:center;vertical-align:middle;border:1px solid black;;' ") [void]$reportSB.Append("rowspan='" + ($resource.Properties.Count + 2) + "'>") $iconPath = Get-IconPath -ResourceName $resource.ResourceName [void]$reportSB.AppendLine("<img src='$iconPath' />") [void]$reportSB.AppendLine("</th>"); [void]$reportSB.AppendLine("<th style='border:1px solid black;text-align:center;vertical-align:middle;background-color:#CCC' colspan='3'>") [void]$reportSB.AppendLine("<h3>$($resource.ResourceName) - $($resource.Key) = $($resource.KeyValue)</h3>") [void]$reportSB.AppendLine("</th></tr>") [void]$reportSB.AppendLine("<tr>") $SourceLabel = "Source Value" $DestinationLabel = "Destination Value" if ($IsBlueprintAssessment) { $SourceLabel = "Tenant's Current Value" $DestinationLabel = "Blueprint's Value" } [void]$reportSB.AppendLine("<td style='text-align:center;border:1px solid black;background-color:#EEE;' width='45%'><strong>Property</strong></td>") [void]$reportSB.AppendLine("<td style='text-align:center;border:1px solid black;background-color:#EEE;' width='15%'><strong>$SourceLabel</strong></td>") [void]$reportSB.AppendLine("<td style='text-align:center;border:1px solid black;background-color:#EEE;' width='15%'><strong>$DestinationLabel</strong></td>") [void]$reportSB.AppendLine("</tr>") foreach ($drift in $resource.Properties) { if ($drift.ParameterName -notlike '_metadata_*') { $cellStyle = '' $emoticon = '' if ($drift._Metadata_Level -eq 'L1') { $cellStyle = "background-color:#F6CECE;" $emoticon = '🟥' } elseif ($drift._Metadata_Level -eq 'L2') { $cellStyle = "background-color:#F7F8E0;" $emoticon = '🟨' } elseif ($drift._Metadata_Level -eq 'L3') { $cellStyle = "background-color:#FFFFFF;" $emoticon = '🟦' } [void]$reportSB.AppendLine("<tr>") [void]$reportSB.AppendLine("<td style='border:1px solid black;text-align:right;' width='45%'>") [void]$reportSB.AppendLine("$($drift.ParameterName)</td>") [void]$reportSB.AppendLine("<td style='border:1px solid black;$cellStyle' width='15%'>") [void]$reportSB.AppendLine("$($drift.ValueInSource)</td>") [void]$reportSB.AppendLine("<td style='border:1px solid black;' width='15%'>") [void]$reportSB.AppendLine("$($drift.ValueInDestination)</td>") [void]$reportSB.AppendLine("</tr>") if ($null -ne $drift._Metadata_Level) { [void]$reportSB.AppendLine("<tr><td colspan='3' style='border:1px solid black;'>$emoticon $($drift._Metadata_Info)</td></tr>") } } } [void]$reportSB.AppendLine("</table><hr/>") } } [void]$reportSB.AppendLine("</body></html>") if (-not [System.String]::IsNullOrEmpty($OutputPath)) { $reportSB.ToString() | Out-File $OutputPath Invoke-Item $OutputPath } else { return $reportSB.ToString() } } Export-ModuleMember -Function @( 'New-M365DSCDeltaReport', 'New-M365DSCReportFromConfiguration' ) |