Private/UI/New-HtmlReport.ps1
|
# Copyright (c) 2026 Sandy Zeng. All rights reserved. # Source-available. All rights reserved. See LICENSE file. <# New-HtmlReport.ps1 — Generates a self-contained HTML diff report from comparison result rows. Author: Sandy Zeng Project: IntuneDiff Version History: 1.0.0 Initial release. #> function New-HtmlReport { <# .SYNOPSIS Generates a self-contained HTML diff report from comparison rows. .PARAMETER Title The report title (shown at the top). .PARAMETER Rows Array of PSCustomObjects to display in the table. .PARAMETER Columns Ordered array of property names to render as columns. .PARAMETER StatusColumn Name of the column used for row color-coding (Result / Status). #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string]$Title, [Parameter(Mandatory)] [object[]]$Rows, [Parameter(Mandatory)] [string[]]$Columns, [string]$StatusColumn = 'Result', [object]$Summary ) $css = @' <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background-color: #f5f5f5; } .container { max-width: 1400px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .report-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 2px solid #e5e7eb; } .branding { display: flex; align-items: center; gap: 15px; } .branding h1 { margin: 0; color: #3b82f6; } .branding p { margin: 0; color: #6b7280; font-size: 14px; } .header-timestamp { text-align: right; color: #6b7280; font-size: 14px; } h2 { color: #1f2937; margin: 20px 0 10px 0; } .info-box { background: #eff6ff; padding: 15px; border-radius: 6px; margin: 20px 0; } .info-box p { margin: 0; color: #374151; font-size: 14px; } .summary { display: flex; gap: 15px; margin: 20px 0; flex-wrap: wrap; } .summary-card { background: #eff6ff; padding: 15px; border-radius: 6px; border-left: 4px solid #3b82f6; min-width: 150px; } .summary-card h3 { margin: 0 0 5px 0; font-size: 24px; color: #3b82f6; } .summary-card p { margin: 0; color: #6b7280; font-size: 14px; } .card { flex: 0 0 auto; padding: 12px 18px; border-radius: 8px; border: 1px solid; min-width: 100px; text-align: center; } .card .num { font-size: 24px; font-weight: 700; } .card .lbl { font-size: 11px; color: #5f6368; margin-top: 2px; text-transform: uppercase; letter-spacing: 0.4px; } .card.covered { background: #e8f7ec; border-color: #a6dbb6; } .card.covered .num { color: #137333; } .card.conflict { background: #fef7e0; border-color: #fadb87; } .card.conflict .num { color: #b06000; } .card.missing { background: #fce8e6; border-color: #f2b8b5; } .card.missing .num { color: #c5221f; } .card.extra { background: #e8f0fe; border-color: #a8c7fa; } .card.extra .num { color: #1967d2; } .card.attention { background: #f3e8fd; border-color: #d6b4fa; } .card.attention .num { color: #7627bb; } table { width: 100%; border-collapse: collapse; margin-top: 20px; table-layout: fixed; } th, td { padding: 12px; text-align: left; border-bottom: 1px solid #e5e7eb; word-wrap: break-word; overflow-wrap: break-word; vertical-align: top; font-size: 13px; } th { background-color: #f9fafb; font-weight: 600; color: #374151; position: sticky; top: 0; } th:nth-child(1), td:nth-child(1) { width: 28%; } th:nth-child(2), td:nth-child(2) { width: 20%; } th:nth-child(3), td:nth-child(3) { width: 12%; } th:nth-child(4), td:nth-child(4) { width: 10%; } th:nth-child(5), td:nth-child(5) { width: 15%; } th:nth-child(6), td:nth-child(6) { width: 15%; } tr:hover { background-color: #f9fafb; } tr.row-conflict { background: #fdecea; } tr.row-missing { background: #fff4e0; } tr.row-extra { background: #e8f1fc; } tr.row-covered { background: #e8f7ec; } tr.row-attention { background: #fff4c0; } tr.row-error { background: #fdecea; } tr.row-succeeded { } .pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; } .pill-conflict { background: #d93025; color: #fff; } .pill-missing { background: #f59e0b; color: #fff; } .pill-extra { background: #1a73e8; color: #fff; } .pill-covered { background: #137333; color: #fff; } .pill-attention{ background: #b45309; color: #fff; } .pill-succeeded{ background: #137333; color: #fff; } .pill-error { background: #d93025; color: #fff; } .pill-not-applicable { background: #6b7280; color: #fff; } tr.row-not-applicable { } .filters { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } .filter-group { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } .filter-label { font-weight: 600; font-size: 13px; color: #374151; margin-right: 4px; } .chip { display: inline-block; padding: 4px 12px; border-radius: 16px; font-size: 12px; font-weight: 500; border: 1px solid #d1d5db; background: #f3f4f6; color: #374151; cursor: pointer; transition: all 0.15s; } .chip.active { background: #3b82f6; color: #fff; border-color: #3b82f6; } .chip:hover { opacity: 0.8; } .result-tabs { display: flex; gap: 4px; margin: 16px 0; flex-wrap: wrap; } .result-tab { padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 500; border: 1px solid #d1d5db; background: #f9fafb; color: #374151; cursor: pointer; transition: all 0.15s; } .result-tab.active { background: #111827; color: #fff; border-color: #111827; } .result-tab:hover { opacity: 0.85; } .search-box { flex: 1; min-width: 200px; } .search-box input { width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; outline: none; } .search-box input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); } .result-count { font-size: 13px; color: #6b7280; margin: 10px 0; } .footer { margin-top: 40px; padding-top: 20px; border-top: 2px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px; } </style> '@ $headerCells = ($Columns | ForEach-Object { "<th>$([System.Net.WebUtility]::HtmlEncode($_))</th>" }) -join '' $bodyRows = foreach ($r in $Rows) { $status = '' if ($r.PSObject.Properties[$StatusColumn]) { $status = [string]$r.$StatusColumn } $cssStatus = $status.ToLowerInvariant() -replace '\s+', '-' $rowClass = "row-$cssStatus" $cells = foreach ($col in $Columns) { $val = $null if ($r.PSObject.Properties[$col]) { $val = $r.$col } if ($null -eq $val) { $val = '' } $encoded = [System.Net.WebUtility]::HtmlEncode([string]$val) if ($col -eq $StatusColumn -and $val) { $pillClass = "pill-$($val.ToString().ToLowerInvariant() -replace '\s+', '-')" "<td><span class='pill $pillClass'>$encoded</span></td>" } else { "<td>$encoded</td>" } } # Build data attributes for filtering $dataAttrs = '' foreach ($col in $Columns) { $v = '' if ($r.PSObject.Properties[$col]) { $v = [string]$r.$col } $safeVal = [System.Net.WebUtility]::HtmlEncode($v) $dataAttrs += " data-$($col.ToLowerInvariant())=`"$safeVal`"" } "<tr class='$rowClass'$dataAttrs>$(($cells) -join '')</tr>" } $generated = (Get-Date).ToString('g') $count = $Rows.Count # Build filter chip data for columns with limited unique values (exclude Result since we have dedicated tabs) $candidateFilterCols = @('PolicyType', 'Status', 'User') | Where-Object { $_ -in $Columns } | Select-Object -Unique $filterColumns = @() foreach ($fc in $candidateFilterCols) { $uniqueVals = @($Rows | ForEach-Object { if ($_.PSObject.Properties[$fc]) { [string]$_.$fc } } | Where-Object { $_ } | Sort-Object -Unique) if ($uniqueVals.Count -gt 0 -and $uniqueVals.Count -le 50) { $filterColumns += $fc } } $filterChipsHtml = '' if ($filterColumns.Count -gt 0) { $filterChipsHtml = "<div class='filters'><div class='search-box'><input type='text' id='searchInput' placeholder='Search settings...' oninput='applyFilters()' /></div>" foreach ($fc in $filterColumns) { $uniqueVals = @($Rows | ForEach-Object { if ($_.PSObject.Properties[$fc]) { [string]$_.$fc } } | Where-Object { $_ } | Sort-Object -Unique) if ($uniqueVals.Count -gt 0) { $filterChipsHtml += "<div class='filter-group'><span class='filter-label'>${fc}:</span>" foreach ($uv in $uniqueVals) { $encoded = [System.Net.WebUtility]::HtmlEncode($uv) $filterChipsHtml += "<button class='chip active' data-filter='$($fc.ToLowerInvariant())' data-value='$encoded' onclick='toggleChip(this)'>$encoded</button>" } $filterChipsHtml += "</div>" } } $filterChipsHtml += "</div>" } $summaryHtml = '' if ($Summary) { $covered = if ($Summary.PSObject.Properties['CoveredSettings']) { [int]$Summary.CoveredSettings } else { 0 } $conflict = if ($Summary.PSObject.Properties['ConflictSettings']) { [int]$Summary.ConflictSettings } else { 0 } $missing = if ($Summary.PSObject.Properties['MissingSettings']) { [int]$Summary.MissingSettings } else { 0 } $extra = if ($Summary.PSObject.Properties['ExtraSettings']) { [int]$Summary.ExtraSettings } else { 0 } $attention = if ($Summary.PSObject.Properties['AttentionSettings']) { [int]$Summary.AttentionSettings } else { 0 } $total = $Rows.Count $summaryHtml = @" <div class='summary'> <div class='card covered'><div class='num'>$covered</div><div class='lbl'>Covered</div></div> <div class='card conflict'><div class='num'>$conflict</div><div class='lbl'>Conflicts</div></div> <div class='card missing'><div class='num'>$missing</div><div class='lbl'>Missing</div></div> <div class='card extra'><div class='num'>$extra</div><div class='lbl'>Extra</div></div> <div class='card attention'><div class='num'>$attention</div><div class='lbl'>Attention</div></div> </div> <div class='result-tabs'> <button class='result-tab active' data-result='all' onclick='filterByResult(this)'>All ($total)</button> <button class='result-tab' data-result='covered' onclick='filterByResult(this)'>Covered ($covered)</button> <button class='result-tab' data-result='conflict' onclick='filterByResult(this)'>Conflict ($conflict)</button> <button class='result-tab' data-result='missing' onclick='filterByResult(this)'>Missing ($missing)</button> <button class='result-tab' data-result='extra' onclick='filterByResult(this)'>Extra ($extra)</button> <button class='result-tab' data-result='attention' onclick='filterByResult(this)'>Attention ($attention)</button> </div> "@ } else { $summaryHtml = @" <div class='summary'> <div class='summary-card'><h3>$count</h3><p>Total Settings</p></div> </div> "@ } # Extract device ID from title if present $infoBoxHtml = '' if ($Title -match '([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})') { $deviceId = $Matches[1] $infoBoxHtml = "<div class='info-box'><p><strong>Device ID:</strong> $deviceId</p></div>" } return @" <!DOCTYPE html> <html lang='en'> <head> <meta charset='utf-8' /> <meta name='viewport' content='width=device-width, initial-scale=1.0' /> <title>$($Title -replace '[<>]', '')</title> $css </head> <body> <div class='container'> <div class='report-header'> <div class='branding'> <div> <h1>IntuneDiff</h1> <p>Intune Policy Comparison Tool</p> </div> </div> <div class='header-timestamp'><strong>Report Generated:</strong><br>$generated</div> </div> <h2>$($Title -replace '–', '–')</h2> $infoBoxHtml $summaryHtml $filterChipsHtml <div class='result-count' id='resultCount'>Showing $count of $count settings</div> <table> <thead><tr>$headerCells</tr></thead> <tbody id='tableBody'> $(($bodyRows) -join "`n") </tbody> </table> <div class='footer'>Generated by <strong>IntuneDiff</strong> - Intune Policy Tool - By Sandy Zeng</div> </div> <script> var activeResultFilter = 'all'; function filterByResult(el) { document.querySelectorAll('.result-tab').forEach(function(t) { t.classList.remove('active'); }); el.classList.add('active'); activeResultFilter = el.getAttribute('data-result'); applyFilters(); } function toggleChip(el) { el.classList.toggle('active'); applyFilters(); } function applyFilters() { var search = (document.getElementById('searchInput') || {}).value || ''; search = search.toLowerCase(); var filters = {}; document.querySelectorAll('.chip').forEach(function(c) { var key = c.getAttribute('data-filter'); if (!filters[key]) filters[key] = []; if (c.classList.contains('active')) filters[key].push(c.getAttribute('data-value')); }); var rows = document.querySelectorAll('#tableBody tr'); var shown = 0; rows.forEach(function(row) { var visible = true; // Result tab filter if (activeResultFilter !== 'all') { var rowResult = (row.getAttribute('data-result') || '').toLowerCase(); if (rowResult !== activeResultFilter) { visible = false; } } // Chip filters if (visible) { for (var key in filters) { if (key === 'result' && activeResultFilter !== 'all') continue; if (filters[key].length === 0) { visible = false; break; } var val = row.getAttribute('data-' + key) || ''; if (filters[key].indexOf(val) === -1) { visible = false; break; } } } if (visible && search) { visible = row.textContent.toLowerCase().indexOf(search) !== -1; } row.style.display = visible ? '' : 'none'; if (visible) shown++; }); var total = rows.length; document.getElementById('resultCount').textContent = 'Showing ' + shown + ' of ' + total + ' settings'; } </script> </body> </html> "@ } |