Private/Report.Html.ps1
|
function ConvertTo-UTCMHtmlText { # Null-safe HTML encoding for text and attribute values. [CmdletBinding()] param([AllowNull()][AllowEmptyString()] $Value) $encoded = [System.Net.WebUtility]::HtmlEncode([string]$Value) # Character references survive document LF normalization and HTML attribute parsing. return $encoded.Replace("`r", ' ').Replace("`n", ' ').Replace("`t", '	') } function ConvertTo-UTCMSafeUrl { # Returns the absolute https URL, or $null for anything else (javascript:, data:, http:, relative ...). [CmdletBinding()] param([AllowNull()][AllowEmptyString()][string] $Url) if ([string]::IsNullOrWhiteSpace($Url)) { return $null } $u = $null if ([uri]::TryCreate($Url.Trim(), [System.UriKind]::Absolute, [ref]$u) -and $u.Scheme -eq 'https') { return $u.AbsoluteUri } return $null } function ConvertTo-UTCMJsonIsland { # Serializes data for a <script type="application/json"> block; neutralizes characters that could # end the script element or confuse parsers. [CmdletBinding()] param([AllowNull()] $InputObject) $json = ConvertTo-Json -InputObject $InputObject -Depth 20 -Compress return $json.Replace('<', '\u003c').Replace('>', '\u003e').Replace('&', '\u0026'). Replace([string][char]0x2028, '\u2028').Replace([string][char]0x2029, '\u2029') } function ConvertTo-UTCMLfText { # Browsers normalize CRLF and CR to LF before hashing inline content, so hash and write LF only. [CmdletBinding()] param([AllowNull()][string] $Text) if ($null -eq $Text) { return '' } return $Text.Replace("`r`n", "`n").Replace("`r", "`n") } function Get-UTCMCspHash { # CSP source expression ('sha256-<base64>') for the given inline element text. [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyString()][string] $Text) $bytes = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-UTCMLfText $Text)) $sha = [System.Security.Cryptography.SHA256]::Create() try { return 'sha256-' + [Convert]::ToBase64String($sha.ComputeHash($bytes)) } finally { $sha.Dispose() } } function Format-UTCMReportDocument { # Wraps a report body in the shared document shell: doctype, language, viewport, CSP (script and # style pinned by hash), shared CSS and script. Output is LF-normalized. [CmdletBinding()] param( [Parameter(Mandatory)][string] $Title, [Parameter(Mandatory)][string] $Body, [Parameter(Mandatory)][string] $ReportType ) $css = "`n" + (ConvertTo-UTCMLfText (Get-UTCMReportCss)) + "`n" $js = "`n" + (ConvertTo-UTCMLfText (Get-UTCMReportScript)) + "`n" $csp = "default-src 'none'; script-src '{0}'; style-src '{1}'; base-uri 'none'; form-action 'none'" -f (Get-UTCMCspHash $js), (Get-UTCMCspHash $css) $t = ConvertTo-UTCMHtmlText $Title $rt = ConvertTo-UTCMHtmlText $ReportType $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="color-scheme" content="light dark"> <meta http-equiv="Content-Security-Policy" content="$csp"> <title>$t</title> <style>$css</style> </head> <body data-report="$rt"> <a class="skip" href="#main">Skip to report</a> <div id="live" class="sr-only" role="status" aria-live="polite"></div> $Body <script>$js</script> </body> </html> "@ return (ConvertTo-UTCMLfText $html) } function Format-UTCMStatusBadge { # Status with icon + text + border style, so it never relies on color alone. [CmdletBinding()] param([Parameter(Mandatory)][ValidateSet('Added','Missing','Changed')][string] $Status) switch ($Status) { 'Added' { return '<span class="badge st-added"><span aria-hidden="true">+</span> Added in Current</span>' } 'Missing' { return '<span class="badge st-missing"><span aria-hidden="true">−</span> Missing in Current</span>' } 'Changed' { return '<span class="badge st-changed"><span aria-hidden="true">≠</span> Changed</span>' } } } function Get-UTCMDriftTypeLabel { # Label used in the drift CSV (and the embedded CSV download) for a Compare-Object side indicator. [CmdletBinding()] param([AllowNull()][string] $SideIndicator) if ($SideIndicator -eq '=>') { return 'Added in Current' } if ($SideIndicator -eq '<=') { return 'Missing in Current' } return 'Changed' } function Format-UTCMCopyable { # Value wrapper; the client script adds the Copy button (keeps large reports small). [CmdletBinding()] param([AllowNull()][AllowEmptyString()][string] $Text, [string] $Tag = 'span', [switch] $IsId) $e = ConvertTo-UTCMHtmlText $Text $cls = if ($IsId) { 'cp cp-id' } else { 'cp' } return "<span class=`"$cls`"><$Tag class=`"v`">$e</$Tag></span>" } function Format-UTCMPropertyTable { # Property table. Mode 'diff': Change | Property | Baseline | Current. Mode 'single': Property | Value. [CmdletBinding()] param( [Parameter(Mandatory)] $Rows, [Parameter(Mandatory)][ValidateSet('diff','single')][string] $Mode, [AllowNull()][string] $Label = 'Properties', [ValidateSet('Baseline','Current')][string] $Side = 'Current', [int] $Max = 500 ) $all = @($Rows) $shown = if ($all.Count -gt $Max) { $all[0..($Max - 1)] } else { $all } $sb = New-Object System.Text.StringBuilder $lbl = ConvertTo-UTCMHtmlText $Label [void]$sb.Append("<div class=`"tablewrap`" role=`"region`" aria-label=`"$lbl`" tabindex=`"0`"><table class=`"props`"><thead><tr>") if ($Mode -eq 'diff') { [void]$sb.Append('<th scope="col">Change</th><th scope="col">Property</th><th scope="col">Baseline</th><th scope="col">Current</th>') } else { [void]$sb.Append('<th scope="col">Property</th><th scope="col">Value</th>') } [void]$sb.Append('</tr></thead><tbody>') foreach ($r in $shown) { $path = [System.Net.WebUtility]::HtmlEncode($r.Path) if ($Mode -eq 'diff') { $st = switch ($r.Status) { 'changed' { '≠ Changed' } 'added' { '+ Added' } 'removed' { '− Removed' } default { '= Same' } } $b = if ($r.Status -eq 'added') { '<span class="absent">(not set)</span>' } else { Format-UTCMCopyable $r.Baseline } $c = if ($r.Status -eq 'removed') { '<span class="absent">(not set)</span>' } else { Format-UTCMCopyable $r.Current } [void]$sb.Append("<tr class=`"prop`" data-st=`"$($r.Status)`"><td><span class=`"pst`">$st</span></td><th scope=`"row`" class=`"path`">$path</th><td>$b</td><td>$c</td></tr>") } else { $val = if ($Side -eq 'Baseline') { $r.Baseline } else { $r.Current } [void]$sb.Append("<tr class=`"prop`" data-st=`"single`"><th scope=`"row`" class=`"path`">$path</th><td>$(Format-UTCMCopyable $val)</td></tr>") } } [void]$sb.Append('</tbody></table></div>') if ($all.Count -gt $Max) { [void]$sb.Append("<p class=`"note`">Showing the first $Max of $($all.Count) properties. See Raw JSON for the full configuration.</p>") } return $sb.ToString() } function Format-UTCMDriftItem { # One <tbody class="item"> for a drift record (Added, Missing or Changed). [CmdletBinding()] param([Parameter(Mandatory)] $Record) $enc = [System.Net.WebUtility] $rec = $Record $status = $rec.Status $name = if ($rec.DisplayName) { $rec.DisplayName } else { '(unnamed)' } $nameAttr = ConvertTo-UTCMHtmlText $rec.DisplayName $rows = $null; $changes = 0; $changesText = '' $printHtml = ''; $tableHtml = ''; $note = ''; $rawHtml = '' if ($status -eq 'Changed') { $rows = Get-UTCMPropertyDiff -BaselineJson $rec.BaselineData -CurrentJson $rec.CurrentData if ($null -ne $rows) { $diffRows = @($rows | Where-Object { $_.Status -ne 'unchanged' }) $changes = $diffRows.Count $changesText = if ($changes -eq 0) { 'Ordering or formatting only' } elseif ($changes -eq 1) { '1 property change' } else { "$changes property changes" } $tableHtml = Format-UTCMPropertyTable -Rows $rows -Mode diff -Label ("Property comparison for " + $name) if ($diffRows.Count -gt 0) { $top = $diffRows | Select-Object -First 5 $li = foreach ($d in $top) { $o = if ($null -ne $d.Baseline) { $enc::HtmlEncode($d.Baseline) } else { '(not set)' } $n = if ($null -ne $d.Current) { $enc::HtmlEncode($d.Current) } else { '(not set)' } "<li><code>$($enc::HtmlEncode($d.Path))</code>: $o → $n</li>" } $more = if ($diffRows.Count -gt 5) { "<li>+$($diffRows.Count - 5) more</li>" } else { '' } $printHtml = "<ul class=`"chg-print`">$($li -join '')$more</ul>" } } else { $changesText = 'Details unavailable' } if ($rec.BaselineDisplayName -and $rec.BaselineDisplayName -ne $rec.DisplayName) { $note = "<p class=`"note`">Renamed from <strong>$($enc::HtmlEncode($rec.BaselineDisplayName))</strong>.</p>" } $rawHtml = "<details class=`"rawwrap`"><summary>Raw JSON</summary><p class=`"note`">Baseline</p><pre class=`"raw`">$($enc::HtmlEncode((Format-UTCMJsonPretty $rec.BaselineData)))</pre>" + "<p class=`"note`">Current</p><pre class=`"raw`">$($enc::HtmlEncode((Format-UTCMJsonPretty $rec.CurrentData)))</pre></details>" } else { $json = if ($status -eq 'Added') { $rec.CurrentData } else { $rec.BaselineData } $rows = if ($status -eq 'Added') { Get-UTCMPropertyDiff -BaselineJson $null -CurrentJson $json } else { Get-UTCMPropertyDiff -BaselineJson $json -CurrentJson $null } if ($null -ne $rows) { $changes = @($rows).Count $changesText = if ($changes -eq 1) { '1 property' } else { "$changes properties" } $side = if ($status -eq 'Added') { 'Current' } else { 'Baseline' } $tableHtml = Format-UTCMPropertyTable -Rows $rows -Mode single -Side $side -Label ("Properties of " + $name) } else { $changesText = 'Details unavailable' } $rawHtml = "<details class=`"rawwrap`"><summary>Raw JSON</summary><pre class=`"raw`">$($enc::HtmlEncode((Format-UTCMJsonPretty $json)))</pre></details>" } $rowIdx = ($rec.RowIndexes -join ',') $idCell = if ($rec.Id) { "<span class=`"idl`" data-col=`"id`">$(Format-UTCMCopyable $rec.Id -Tag 'code' -IsId)</span>" } else { '' } $badge = Format-UTCMStatusBadge -Status $status return @" <tbody class="item" data-status="$status" data-workload="$(ConvertTo-UTCMHtmlText $rec.Workload)" data-type="$(ConvertTo-UTCMHtmlText $rec.Type)" data-name="$nameAttr" data-id="$(ConvertTo-UTCMHtmlText $rec.Id)" data-changes="$changes" data-rows="$rowIdx"> <tr class="main"><td data-label="Status">$badge</td><td data-label="Resource"><span class="nm">$($enc::HtmlEncode($name))</span>$idCell</td><td data-col="type" data-label="Type">$($enc::HtmlEncode($rec.Type))</td><td data-col="workload" data-label="Workload">$($enc::HtmlEncode($rec.Workload))</td><td data-col="changes" data-label="Changes">$changesText</td></tr> <tr class="detail"><td colspan="5">$printHtml<details><summary>Show property details</summary>$note$tableHtml$rawHtml</details></td></tr> </tbody> "@ } function Format-UTCMInventoryItem { # One <tbody class="item"> for a snapshot / current-state configuration item. [CmdletBinding()] param([Parameter(Mandatory)] $Item) $enc = [System.Net.WebUtility] $name = if ($Item.DisplayName) { $Item.DisplayName } else { '(unnamed)' } $json = '' if ($null -ne $Item.Data) { try { $json = $Item.Data | ConvertTo-Json -Depth 99 } catch { $json = [string]$Item.Data } } $preview = $enc::HtmlEncode((Get-UTCMItemPreview -Data $Item.Data)) $idCell = if ($Item.Id) { "<span class=`"idl`" data-col=`"id`">$(Format-UTCMCopyable $Item.Id -Tag 'code' -IsId)</span>" } else { '' } $wl = ConvertTo-UTCMHtmlText $Item.Workload return @" <tbody class="item" data-workload="$wl" data-type="$(ConvertTo-UTCMHtmlText $Item.Type)" data-name="$(ConvertTo-UTCMHtmlText $Item.DisplayName)" data-id="$(ConvertTo-UTCMHtmlText $Item.Id)"> <tr class="main"><td data-label="Resource"><span class="nm">$($enc::HtmlEncode($name))</span>$idCell</td><td data-col="type" data-label="Type">$($enc::HtmlEncode($Item.Type))</td><td data-col="workload" data-label="Workload">$wl</td><td data-col="preview" data-label="Preview">$preview</td></tr> <tr class="detail"><td colspan="4"><details><summary>Configuration JSON</summary><pre class="raw">$($enc::HtmlEncode($json))</pre></details></td></tr> </tbody> "@ } function Format-UTCMSummaryCard { [CmdletBinding()] param([Parameter(Mandatory)] $Cards) $li = foreach ($c in $Cards) { $cls = if ($c['Class']) { " $($c['Class'])" } else { '' } $icon = if ($c['Icon']) { "<span aria-hidden=`"true`">$($c['Icon'])</span> " } else { '' } "<li class=`"card$cls`"><span class=`"n`">$($c['Value'])</span><span class=`"l`">$icon$([System.Net.WebUtility]::HtmlEncode([string]$c['Label']))</span></li>" } return "<ul class=`"cards`" aria-label=`"Summary`">$($li -join '')</ul>" } function Format-UTCMDistribution { # Horizontal distribution drawn with SVG (width is an attribute, so no inline style is needed and the # strict CSP holds in every engine), with the number always shown as text. [CmdletBinding()] param([Parameter(Mandatory)][string] $Title, [AllowNull()] $Rows, [int] $Top = 12) $all = @($Rows) if ($all.Count -eq 0) { return '' } $max = [double](($all | Measure-Object -Property Count -Maximum).Maximum) $shown = @($all | Select-Object -First $Top) $inv = [cultureinfo]::InvariantCulture $li = foreach ($r in $shown) { $n = [System.Net.WebUtility]::HtmlEncode([string]$r.Name) $pct = if ($max -gt 0) { [math]::Round(100.0 * $r.Count / $max, 1).ToString($inv) } else { '0' } "<li><span class=`"dn`">$n</span><svg class=`"bar`" viewBox=`"0 0 100 10`" preserveAspectRatio=`"none`" role=`"img`" aria-label=`"$n`: $($r.Count)`"><rect class=`"bar-bg`" width=`"100`" height=`"10`"/><rect class=`"bar-fg`" width=`"$pct`" height=`"10`"/></svg><span class=`"dc`">$($r.Count)</span></li>" } $more = if ($all.Count -gt $Top) { "<p class=`"note`">Showing the top $Top of $($all.Count).</p>" } else { '' } return "<section aria-label=`"$([System.Net.WebUtility]::HtmlEncode($Title))`"><h2>$([System.Net.WebUtility]::HtmlEncode($Title))</h2><ul class=`"dist`">$($li -join '')</ul>$more</section>" } function Format-UTCMToolbar { # Search, filters, sort, column chooser, downloads and actions. Everything is hidden without JS. [CmdletBinding()] param( [Parameter(Mandatory)][string] $ScopeId, [AllowNull()] $Statuses, [AllowNull()] $Workloads, [AllowNull()] $Types, [Parameter(Mandatory)] $SortOptions, [Parameter(Mandatory)] $Columns, [switch] $ChangedToggle, [switch] $GroupToggle, [switch] $GroupButtons ) $enc = [System.Net.WebUtility] $sid = $enc::HtmlEncode($ScopeId) $sb = New-Object System.Text.StringBuilder [void]$sb.Append("<div class=`"toolbar needs-js`" role=`"search`" aria-label=`"Search and filter`">") [void]$sb.Append("<div class=`"fld search`"><label for=`"q-$sid`">Search</label><input id=`"q-$sid`" type=`"search`" data-f=`"q`" maxlength=`"200`" placeholder=`"Name, ID, type or property`" autocomplete=`"off`"></div>") if ($Statuses) { [void]$sb.Append('<div class="chips" role="group" aria-label="Filter by status">') foreach ($s in $Statuses) { [void]$sb.Append("<button type=`"button`" class=`"chip`" data-status=`"$($enc::HtmlEncode($s.Value))`" aria-pressed=`"false`">$($s.Icon) $($enc::HtmlEncode($s.Label)) ($($s.Count))</button>") } [void]$sb.Append('</div>') } foreach ($pair in @(@('workload', 'Workload', $Workloads, 'All workloads'), @('type', 'Resource type', $Types, 'All types'))) { if ($pair[2] -and @($pair[2]).Count -gt 0) { [void]$sb.Append("<div class=`"fld`"><label for=`"$($pair[0])-$sid`">$($pair[1])</label><select id=`"$($pair[0])-$sid`" data-f=`"$($pair[0])`"><option value=`"`">$($pair[3])</option>") foreach ($o in $pair[2]) { $ov = ConvertTo-UTCMHtmlText $o; [void]$sb.Append("<option value=`"$ov`">$ov</option>") } [void]$sb.Append('</select></div>') } } [void]$sb.Append("<div class=`"fld`"><label for=`"sort-$sid`">Sort by</label><select id=`"sort-$sid`" data-f=`"sort`">") foreach ($o in $SortOptions) { [void]$sb.Append("<option value=`"$($enc::HtmlEncode($o.Value))`">$($enc::HtmlEncode($o.Label))</option>") } [void]$sb.Append('</select></div>') if ($ChangedToggle) { [void]$sb.Append("<label class=`"fld chk`"><input type=`"checkbox`" data-f=`"changed`" checked> Show only changed properties</label>") } if ($GroupToggle) { [void]$sb.Append("<label class=`"fld chk`"><input type=`"checkbox`" data-f=`"group`" checked> Group by workload</label>") } [void]$sb.Append('<details class="menu"><summary>Columns</summary><div class="menu-body">') foreach ($c in $Columns) { [void]$sb.Append("<label><input type=`"checkbox`" data-col-toggle=`"$($enc::HtmlEncode($c.Name))`" checked> $($enc::HtmlEncode($c.Label))</label>") } [void]$sb.Append('</div></details>') [void]$sb.Append('<details class="menu"><summary>Download</summary><div class="menu-body">' + '<button type="button" data-dl="csv-filtered">CSV (filtered)</button><button type="button" data-dl="csv-all">CSV (all)</button>' + '<button type="button" data-dl="json-filtered">JSON (filtered)</button><button type="button" data-dl="json-all">JSON (all)</button></div></details>') [void]$sb.Append('<button type="button" data-act="expand">Expand visible</button><button type="button" data-act="collapse">Collapse visible</button>') if ($GroupButtons) { [void]$sb.Append('<button type="button" data-act="groups-open">Open all groups</button><button type="button" data-act="groups-close">Close all groups</button>') } [void]$sb.Append('<button type="button" data-act="copylink">Copy link</button><button type="button" data-act="clear">Clear filters</button></div>') return $sb.ToString() } function Format-UTCMTableHead { [CmdletBinding()] param([Parameter(Mandatory)] $Columns) $th = foreach ($c in $Columns) { $col = if ($c['DataCol']) { " data-col=`"$($c['DataCol'])`"" } else { '' } $inner = if ($c['SortKey']) { "<button type=`"button`" class=`"sortbtn`" data-sort=`"$($c['SortKey'])`">$($c['Label'])</button>" } else { "<span class=`"thl`">$($c['Label'])</span>" } $aria = if ($c['SortKey']) { ' aria-sort="none"' } else { '' } "<th scope=`"col`"$col$aria>$inner</th>" } return "<thead><tr>$($th -join '')</tr></thead>" } function Format-UTCMSnapshotDiagnosticNotice { [CmdletBinding()] [OutputType([string])] param([AllowNull()] $Diagnostic) $incomplete = @($Diagnostic | Where-Object { $null -ne $_ -and $_.status -ne 'succeeded' }) if ($incomplete.Count -eq 0) { return '' } $sb = [System.Text.StringBuilder]::new() [void]$sb.Append('<aside class="empty card k-missing" role="alert"><h2>Incomplete extraction - conclusions are provisional</h2><p>Missing or added rows may reflect extraction failures. An empty comparison cannot establish absence of drift. Do not use these results as unattended drift conclusions.</p><ul>') foreach ($entry in $incomplete) { $id = ConvertTo-UTCMHtmlText $entry.id $status = ConvertTo-UTCMHtmlText $entry.status $summary = ConvertTo-UTCMHtmlText $entry.errorSummary [void]$sb.Append("<li>Snapshot <code>$id</code>: <strong>$status</strong>. $summary</li>") } [void]$sb.Append('</ul></aside>') $sb.ToString() } function Format-UTCMDriftReportHtml { # Builds the complete drift report document. [CmdletBinding()] param( [AllowNull()] $Records, [AllowNull()] $Diff, [Parameter(Mandatory)][string] $SnapshotId, [AllowNull()][string] $BaselineName, [AllowNull()] $BaselineCreated, [AllowNull()][string] $ResourceLocation, [AllowNull()] $CurrentItems, [AllowNull()] $SnapshotDiagnostics, [Parameter(Mandatory)][string] $GeneratedUtc ) $enc = [System.Net.WebUtility] $recs = @($Records | Where-Object { $null -ne $_ }) $added = @($recs | Where-Object Status -eq 'Added').Count $missing = @($recs | Where-Object Status -eq 'Missing').Count $changed = @($recs | Where-Object Status -eq 'Changed').Count $items = @($CurrentItems | Where-Object { $null -ne $_ } | ForEach-Object { ConvertTo-UTCMReportItem $_ }) $incomplete = @($SnapshotDiagnostics | Where-Object { $null -ne $_ -and $_.status -ne 'succeeded' }).Count -gt 0 $title = if ($incomplete) { 'UTCM Drift Report - Incomplete Comparison' } else { 'UTCM Drift Report' } $currentStateNote = if ($incomplete) { 'Configuration returned by the current snapshot; extraction may be incomplete. See the warnings above.' } else { 'Complete configuration captured at the time of comparison.' } $link = '' $safe = ConvertTo-UTCMSafeUrl $ResourceLocation if ($safe) { $link = " · <a href=`"$($enc::HtmlEncode($safe))`" target=`"_blank`" rel=`"noopener noreferrer`">Download baseline JSON</a>" } $nav = '<a href="#drift-summary" data-nav>Drift summary</a>' if ($items.Count -gt 0) { $nav += " <a href=`"#current-state`" data-nav>Full current state ($($items.Count))</a>" } $sb = New-Object System.Text.StringBuilder [void]$sb.Append(@" <div class="wrap"> <header> <h1 id="drift-summary">$title</h1> <p class="meta">Baseline snapshot: <strong>$($enc::HtmlEncode($BaselineName))</strong> (<code>$($enc::HtmlEncode($SnapshotId))</code>) · Baseline created: $($enc::HtmlEncode((ConvertTo-UTCMUtcDisplay $BaselineCreated))) · Generated: $($enc::HtmlEncode($GeneratedUtc))$link</p> <p class="note">This report contains tenant configuration data. Handle it as confidential.</p> <nav class="topnav" aria-label="Report sections">$nav</nav> </header> <main id="main"> $(Format-UTCMSnapshotDiagnosticNotice -Diagnostic $SnapshotDiagnostics) $(Format-UTCMSummaryCard -Cards @( @{ Label = 'Resources with drift'; Value = $recs.Count; Class = ''; Icon = '' }, @{ Label = 'Changed'; Value = $changed; Class = 'k-changed'; Icon = '≠' }, @{ Label = 'Missing in current'; Value = $missing; Class = 'k-missing'; Icon = '−' }, @{ Label = 'Added in current'; Value = $added; Class = 'k-added'; Icon = '+' } )) "@) if ($recs.Count -eq 0) { if ($incomplete) { [void]$sb.Append('<p class="empty" role="status"><strong>No differences observed in the extracted data.</strong> This incomplete comparison cannot establish absence of drift.</p>') } else { [void]$sb.Append('<p class="empty ok" role="status"><strong>No drift detected.</strong> The current configuration matches the baseline.</p>') } } else { [void]$sb.Append((Format-UTCMDistribution -Title 'Drift by workload' -Rows (Get-UTCMCountBy -Items $recs -Property Workload))) [void]$sb.Append((Format-UTCMDistribution -Title 'Drift by resource type' -Rows (Get-UTCMCountBy -Items $recs -Property Type) -Top 10)) $islandRows = foreach ($d in @($Diff | Where-Object { $null -ne $_ })) { [ordered]@{ Id = [string](Get-UTCMObjectField $d @('id')); DisplayName = [string](Get-UTCMObjectField $d @('displayName')) Type = [string](Get-UTCMObjectField $d @('type','resourceType')); DriftType = (Get-UTCMDriftTypeLabel ([string](Get-UTCMObjectField $d @('SideIndicator')))) NormalizedData = [string](Get-UTCMObjectField $d @('normalizedData')) } } $statuses = @( @{ Value = 'Changed'; Label = 'Changed'; Icon = '≠'; Count = $changed }, @{ Value = 'Missing'; Label = 'Missing in current'; Icon = '−'; Count = $missing }, @{ Value = 'Added'; Label = 'Added in current'; Icon = '+'; Count = $added } ) $toolbar = Format-UTCMToolbar -ScopeId 'drift' -Statuses $statuses ` -Workloads @($recs | ForEach-Object Workload | Sort-Object -Unique) -Types @($recs | ForEach-Object Type | Sort-Object -Unique) ` -SortOptions @(@{ Value = 'status'; Label = 'Status' }, @{ Value = 'name'; Label = 'Resource name' }, @{ Value = 'type'; Label = 'Type' }, @{ Value = 'workload'; Label = 'Workload' }, @{ Value = 'changes'; Label = 'Number of changes' }) ` -Columns @(@{ Name = 'id'; Label = 'Resource ID' }, @{ Name = 'type'; Label = 'Type' }, @{ Name = 'workload'; Label = 'Workload' }, @{ Name = 'changes'; Label = 'Changes' }) ` -ChangedToggle $head = Format-UTCMTableHead -Columns @( @{ Label = 'Status'; SortKey = 'status' }, @{ Label = 'Resource'; SortKey = 'name' }, @{ Label = 'Type'; SortKey = 'type'; DataCol = 'type' }, @{ Label = 'Workload'; SortKey = 'workload'; DataCol = 'workload' }, @{ Label = 'Changes'; SortKey = 'changes'; DataCol = 'changes' }) $body = New-Object System.Text.StringBuilder foreach ($r in $recs) { [void]$body.Append((Format-UTCMDriftItem -Record $r)) } [void]$sb.Append(@" <section class="scope changed-only" data-scope="drift" data-dlname="UTCM-Drift-Report-$($enc::HtmlEncode($SnapshotId))" data-download="island" data-island="utcm-drift-data" data-sort="status" aria-labelledby="drift-h"> <h2 id="drift-h">Drift details</h2> $toolbar <p class="count" data-count></p> <p class="empty" data-empty hidden>No resources match the current search and filters.</p> <div data-list data-flat><table id="drifttable" class="rtable" aria-label="UTCM drift report"><caption class="sr-only">Resources that differ from the baseline</caption>$head$($body.ToString())</table><div data-pager></div></div> <script type="application/json" id="utcm-drift-data">{"rows":$(ConvertTo-UTCMJsonIsland -InputObject @($islandRows))}</script> </section> "@) } if ($items.Count -gt 0) { $toolbar = Format-UTCMToolbar -ScopeId 'current' -Workloads @($items | ForEach-Object Workload | Sort-Object -Unique) -Types @($items | ForEach-Object Type | Sort-Object -Unique) ` -SortOptions @(@{ Value = 'name'; Label = 'Resource name' }, @{ Value = 'type'; Label = 'Type' }, @{ Value = 'workload'; Label = 'Workload' }) ` -Columns @(@{ Name = 'id'; Label = 'Resource ID' }, @{ Name = 'type'; Label = 'Type' }, @{ Name = 'workload'; Label = 'Workload' }, @{ Name = 'preview'; Label = 'Preview' }) $head = Format-UTCMTableHead -Columns @( @{ Label = 'Resource'; SortKey = 'name' }, @{ Label = 'Type'; SortKey = 'type'; DataCol = 'type' }, @{ Label = 'Workload'; SortKey = 'workload'; DataCol = 'workload' }, @{ Label = 'Preview'; DataCol = 'preview' }) $body = New-Object System.Text.StringBuilder foreach ($it in (Get-UTCMSortedReportItem $items)) { [void]$body.Append((Format-UTCMInventoryItem -Item $it)) } [void]$sb.Append(@" <section class="scope" id="current-state" data-scope="current" data-dlname="UTCM-Current-State-$($enc::HtmlEncode($SnapshotId))" data-download="dom" data-sort="name" data-presorted="1" aria-labelledby="current-h"> <h2 id="current-h">Full current state ($($items.Count) items)</h2> <p class="note">$currentStateNote</p> $toolbar <p class="count" data-count></p> <p class="empty" data-empty hidden>No resources match the current search and filters.</p> <div data-list data-flat><table id="currentTable" class="rtable" aria-label="Current configuration state"><caption class="sr-only">All current configuration items</caption>$head$($body.ToString())</table><div data-pager></div></div> </section> "@) } [void]$sb.Append('</main></div>') return (Format-UTCMReportDocument -Title $title -Body $sb.ToString() -ReportType 'drift') } function Format-UTCMSnapshotReportHtml { # Builds the complete snapshot export document. [CmdletBinding()] param( [AllowNull()] $Items, [AllowNull()][string] $SnapshotName, [Parameter(Mandatory)][string] $SnapshotId, [AllowNull()] $Created, [AllowNull()][string] $Status, [AllowNull()] $SnapshotDiagnostic, [Parameter(Mandatory)][string] $GeneratedUtc ) $enc = [System.Net.WebUtility] $list = @($Items | Where-Object { $null -ne $_ } | ForEach-Object { ConvertTo-UTCMReportItem $_ }) $byWorkload = @(Get-UTCMCountBy -Items $list -Property Workload) $types = @($list | ForEach-Object Type | Sort-Object -Unique) if ($Status -eq 'partiallySuccessful' -and $null -eq $SnapshotDiagnostic) { $SnapshotDiagnostic = Get-UTCMSnapshotDiagnostic -Snapshot @{ id = $SnapshotId; status = $Status } } $sb = New-Object System.Text.StringBuilder [void]$sb.Append(@" <div class="wrap"> <header> <h1 id="snapshot-top">UTCM Snapshot Export</h1> <p class="meta">Snapshot: <strong>$($enc::HtmlEncode($SnapshotName))</strong> (<code>$($enc::HtmlEncode($SnapshotId))</code>) · Created: $($enc::HtmlEncode((ConvertTo-UTCMUtcDisplay $Created))) · Status: $($enc::HtmlEncode($Status)) · Items: $($list.Count) · Generated: $($enc::HtmlEncode($GeneratedUtc))</p> <p class="note">This report contains tenant configuration data. Handle it as confidential.</p> </header> <main id="main"> $(Format-UTCMSnapshotDiagnosticNotice -Diagnostic $SnapshotDiagnostic) $(Format-UTCMSummaryCard -Cards @( @{ Label = 'Items'; Value = $list.Count; Class = '' }, @{ Label = 'Workloads'; Value = $byWorkload.Count; Class = '' }, @{ Label = 'Resource types'; Value = $types.Count; Class = '' } )) "@) if ($list.Count -eq 0) { [void]$sb.Append('<p class="empty" role="status"><strong>This snapshot contains no items.</strong> A partially successful job can return no data for some resource types; check the job error details.</p>') } else { [void]$sb.Append((Format-UTCMDistribution -Title 'Items by workload' -Rows $byWorkload)) $toolbar = Format-UTCMToolbar -ScopeId 'snap' -Workloads @($byWorkload | ForEach-Object Name) -Types $types ` -SortOptions @(@{ Value = 'name'; Label = 'Resource name' }, @{ Value = 'type'; Label = 'Type' }, @{ Value = 'workload'; Label = 'Workload' }) ` -Columns @(@{ Name = 'id'; Label = 'Resource ID' }, @{ Name = 'type'; Label = 'Type' }, @{ Name = 'workload'; Label = 'Workload' }, @{ Name = 'preview'; Label = 'Preview' }) ` -GroupToggle -GroupButtons $head = Format-UTCMTableHead -Columns @( @{ Label = 'Resource'; SortKey = 'name' }, @{ Label = 'Type'; SortKey = 'type'; DataCol = 'type' }, @{ Label = 'Workload'; SortKey = 'workload'; DataCol = 'workload' }, @{ Label = 'Preview'; DataCol = 'preview' }) [void]$sb.Append(@" <section class="scope" data-scope="snapshot" data-dlname="Snapshot-$($enc::HtmlEncode($SnapshotId))" data-download="dom" data-groupable="1" data-sort="name" data-presorted="1" aria-labelledby="snap-h"> <h2 id="snap-h">Resources</h2> $toolbar <p class="count" data-count></p> <p class="empty" data-empty hidden>No resources match the current search and filters.</p> <div data-grouped-wrap> "@) $openAll = ($list.Count -le 500) -or ($byWorkload.Count -eq 1) $groups = $list | Group-Object -Property Workload foreach ($g in ($groups | Sort-Object Name)) { $wl = ConvertTo-UTCMHtmlText $g.Name $rows = New-Object System.Text.StringBuilder foreach ($it in (Get-UTCMSortedReportItem $g.Group)) { [void]$rows.Append((Format-UTCMInventoryItem -Item $it)) } $open = if ($openAll) { ' open' } else { '' } [void]$sb.Append("<details class=`"wl-group`" data-wl=`"$wl`"$open><summary>$wl <span class=`"gc`">($($g.Count))</span></summary><div data-list data-group=`"$wl`"><table class=`"rtable`"><caption class=`"sr-only`">$wl resources</caption>$head$($rows.ToString())</table><div data-pager></div></div></details>") } [void]$sb.Append("</div><div data-flat-wrap hidden><div data-list data-flat><table id=`"snapTable`" class=`"rtable`"><caption class=`"sr-only`">All resources</caption>$head</table><div data-pager></div></div></div></section>") } [void]$sb.Append('</main></div>') return (Format-UTCMReportDocument -Title 'UTCM Snapshot Export' -Body $sb.ToString() -ReportType 'snapshot') } |