Private/ConvertTo-InfraPulseComparisonHtmlReport.ps1
|
function ConvertTo-InfraPulseComparisonHtmlReport { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]]$Comparisons, [Parameter(Mandatory)] [string]$Title ) $comparisonArray = @($Comparisons) if ($comparisonArray.Count -eq 0) { throw 'At least one comparison is required to build an HTML document.' } $totals = [ordered]@{ NewFinding = 0 Regressed = 0 Resolved = 0 Improved = 0 Changed = 0 NotComparable = 0 Added = 0 Unchanged = 0 } foreach ($comparison in $comparisonArray) { foreach ($changeTypeName in @($totals.Keys)) { $totals[$changeTypeName] = [int]$totals[$changeTypeName] + [int]$comparison.Summary.$changeTypeName } } $hasRegressions = @($comparisonArray | Where-Object { [bool]$_.HasRegressions }).Count -gt 0 $verdictClass = if ($hasRegressions) { 'critical' } else { 'healthy' } $verdictText = if ($hasRegressions) { '{0} regression(s) detected' -f ([int]$totals.NewFinding + [int]$totals.Regressed) } else { 'No regressions detected' } $generatedAt = [DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss') + ' UTC' $css = @' :root { color-scheme: dark; --bg: #080c16; --panel: #101827; --panel-2: #151f31; --line: #26334a; --line-soft: rgba(38, 51, 74, .72); --text: #edf4ff; --muted: #9fb0c7; --accent: #63e6be; --accent-2: #78a9ff; --healthy: #45d483; --warning: #f7c948; --critical: #ff6b6b; --unknown: #8aa4c8; --skipped: #66758a; --shadow: 0 18px 55px rgba(0, 0, 0, .28); } * { box-sizing: border-box; } body { margin: 0; background: radial-gradient(circle at 15% 0%, rgba(99, 230, 190, .09), transparent 36rem), var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.5; } main { width: min(1480px, calc(100% - 32px)); margin: 32px auto 64px; } .hero { padding: 30px; border: 1px solid var(--line); border-radius: 20px; background: linear-gradient(145deg, rgba(21, 31, 49, .97), rgba(10, 16, 28, .97)); box-shadow: var(--shadow); } .eyebrow { color: var(--accent); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; letter-spacing: .16em; text-transform: uppercase; } h1 { margin: 8px 0 4px; font-size: clamp(2rem, 5vw, 3.4rem); line-height: 1; letter-spacing: -.04em; } .subtitle { margin: 12px 0 0; color: var(--muted); max-width: 760px; } .verdict { display: inline-flex; align-items: center; gap: 8px; margin-top: 18px; padding: 8px 12px; border-radius: 999px; font-weight: 700; border: 1px solid currentColor; } .verdict::before, .status::before, .change::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; box-shadow: 0 0 14px currentColor; } .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin: 18px 0; } .metric { padding: 16px 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); } .metric .value { display: block; font-size: 1.65rem; font-weight: 800; font-variant-numeric: tabular-nums; } .metric .label { color: var(--muted); font-size: .74rem; letter-spacing: .06em; text-transform: uppercase; } .host { margin: 28px 0; border: 1px solid var(--line); border-radius: 18px; overflow: hidden; background: var(--panel); box-shadow: var(--shadow); } .host-header { display: flex; justify-content: space-between; gap: 20px; align-items: center; padding: 22px 24px; background: var(--panel-2); border-bottom: 1px solid var(--line); } .host-title { margin: 0; font-size: 1.35rem; } .host-meta { margin-top: 4px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .82rem; } .table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; min-width: 1080px; } th { padding: 12px 14px; color: var(--muted); background: rgba(255,255,255,.018); border-bottom: 1px solid var(--line); text-align: left; font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; } td { padding: 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; font-size: .88rem; } tr:last-child td { border-bottom: 0; } .status, .change { display: inline-flex; align-items: center; gap: 7px; padding: 4px 9px; border: 1px solid currentColor; border-radius: 999px; font-weight: 750; font-size: .75rem; white-space: nowrap; } .healthy { color: var(--healthy); } .warning { color: var(--warning); } .critical { color: var(--critical); } .unknown { color: var(--unknown); } .skipped { color: var(--skipped); } .accent { color: var(--accent); } .accent-2 { color: var(--accent-2); } .muted { color: var(--muted); } .mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .82rem; } .message { max-width: 420px; } footer { margin-top: 28px; color: var(--muted); text-align: center; font-size: .8rem; } @media print { :root { color-scheme: light; --bg: #fff; --panel: #fff; --panel-2: #f4f6f8; --line: #d9dee5; --line-soft: #e5e7eb; --text: #111827; --muted: #4b5563; --shadow: none; } body { background: #fff; } main { width: 100%; margin: 0; } .host { break-inside: avoid; box-shadow: none; } } '@ $builder = New-Object System.Text.StringBuilder $null = $builder.AppendLine('<!doctype html>') $null = $builder.AppendLine('<html lang="en">') $null = $builder.AppendLine('<head>') $null = $builder.AppendLine('<meta charset="utf-8">') $null = $builder.AppendLine('<meta name="viewport" content="width=device-width, initial-scale=1">') $null = $builder.AppendLine('<meta name="robots" content="noindex,nofollow,noarchive">') $null = $builder.AppendLine('<meta http-equiv="Content-Security-Policy" content="default-src ''none''; style-src ''unsafe-inline''; img-src data:; base-uri ''none''; form-action ''none''">') $null = $builder.AppendLine('<meta name="generator" content="InfraPulse ' + (ConvertTo-InfraPulseHtmlEncoded -Value $script:InfraPulseModuleVersion) + '">') $null = $builder.AppendLine('<title>' + (ConvertTo-InfraPulseHtmlEncoded -Value $Title) + '</title>') $null = $builder.AppendLine('<style>' + $css + '</style>') $null = $builder.AppendLine('</head>') $null = $builder.AppendLine('<body><main>') $null = $builder.AppendLine('<section class="hero">') $null = $builder.AppendLine('<div class="eyebrow">InfraPulse // change evidence</div>') $null = $builder.AppendLine('<h1>' + (ConvertTo-InfraPulseHtmlEncoded -Value $Title) + '</h1>') $null = $builder.AppendLine('<p class="subtitle">Classified differences between two InfraPulse snapshots. Generated ' + $generatedAt + '.</p>') $null = $builder.AppendLine('<div class="verdict ' + $verdictClass + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $verdictText) + '</div>') $null = $builder.AppendLine('</section>') $metricClasses = @{ NewFinding = ' critical' Regressed = ' critical' Resolved = ' healthy' Improved = ' accent' Changed = ' warning' NotComparable = ' skipped' Added = ' accent-2' Unchanged = '' } $null = $builder.AppendLine('<section class="summary-grid" aria-label="Change summary">') foreach ($changeTypeName in $totals.Keys) { $null = $builder.AppendLine('<div class="metric' + $metricClasses[$changeTypeName] + '"><span class="value">' + [int]$totals[$changeTypeName] + '</span><span class="label">' + $changeTypeName + '</span></div>') } $null = $builder.AppendLine('</section>') foreach ($comparison in $comparisonArray) { $hostVerdictClass = if ([bool]$comparison.HasRegressions) { 'critical' } else { 'healthy' } $hostVerdictText = if ([bool]$comparison.HasRegressions) { 'Regressions' } else { 'No regressions' } $metaParts = @() if ($null -ne $comparison.Reference -and $null -ne $comparison.Reference.GeneratedAtUtc) { $metaParts += 'before ' + ([datetime]$comparison.Reference.GeneratedAtUtc).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') + ' UTC' } if ($null -ne $comparison.Difference -and $null -ne $comparison.Difference.GeneratedAtUtc) { $metaParts += 'after ' + ([datetime]$comparison.Difference.GeneratedAtUtc).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') + ' UTC' } if ($null -eq $comparison.ConfigurationMatches) { $metaParts += 'configuration match unknown' } elseif ([bool]$comparison.ConfigurationMatches) { $metaParts += 'same configuration' } else { $metaParts += 'DIFFERENT configuration' } $null = $builder.AppendLine('<article class="host">') $null = $builder.AppendLine('<header class="host-header"><div><h2 class="host-title">' + (ConvertTo-InfraPulseHtmlEncoded -Value $comparison.ComputerName) + '</h2><div class="host-meta">' + (ConvertTo-InfraPulseHtmlEncoded -Value ($metaParts -join ' | ')) + '</div></div><span class="status ' + $hostVerdictClass + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $hostVerdictText) + '</span></header>') $null = $builder.AppendLine('<div class="table-wrap"><table>') $null = $builder.AppendLine('<thead><tr><th>Change</th><th>Check</th><th>Target</th><th>Before</th><th>After</th><th>Observed</th><th>Message</th></tr></thead><tbody>') foreach ($change in @($comparison.Changes)) { $changeClass = switch ([string]$change.ChangeType) { 'NewFinding' { 'critical' } 'Regressed' { 'critical' } 'Resolved' { 'healthy' } 'Improved' { 'accent' } 'Changed' { 'warning' } 'NotComparable' { 'skipped' } 'Added' { 'accent-2' } default { 'muted' } } $beforeStatus = if ([string]::IsNullOrWhiteSpace([string]$change.ReferenceStatus)) { '<span class="muted">-</span>' } else { '<span class="status ' + (Get-InfraPulseStatusCssClass -Status ([string]$change.ReferenceStatus)) + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $change.ReferenceStatus) + '</span>' } $afterStatus = if ([string]::IsNullOrWhiteSpace([string]$change.DifferenceStatus)) { '<span class="muted">-</span>' } else { '<span class="status ' + (Get-InfraPulseStatusCssClass -Status ([string]$change.DifferenceStatus)) + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $change.DifferenceStatus) + '</span>' } $observedBefore = if ([string]::IsNullOrWhiteSpace([string]$change.ReferenceObservedValue)) { '-' } else { [string]$change.ReferenceObservedValue } $observedAfter = if ([string]::IsNullOrWhiteSpace([string]$change.DifferenceObservedValue)) { '-' } else { [string]$change.DifferenceObservedValue } $message = if (-not [string]::IsNullOrWhiteSpace([string]$change.DifferenceMessage)) { [string]$change.DifferenceMessage } else { [string]$change.ReferenceMessage } $null = $builder.AppendLine('<tr>') $null = $builder.AppendLine('<td><span class="change ' + $changeClass + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $change.ChangeType) + '</span></td>') $null = $builder.AppendLine('<td><span class="mono">' + (ConvertTo-InfraPulseHtmlEncoded -Value $change.CheckName) + '</span><br><span class="muted">' + (ConvertTo-InfraPulseHtmlEncoded -Value $change.Category) + '</span></td>') $null = $builder.AppendLine('<td>' + (ConvertTo-InfraPulseHtmlEncoded -Value $change.Target) + '</td>') $null = $builder.AppendLine('<td>' + $beforeStatus + '</td>') $null = $builder.AppendLine('<td>' + $afterStatus + '</td>') $null = $builder.AppendLine('<td class="mono">' + (ConvertTo-InfraPulseHtmlEncoded -Value $observedBefore) + ' → ' + (ConvertTo-InfraPulseHtmlEncoded -Value $observedAfter) + '</td>') $null = $builder.AppendLine('<td class="message">' + (ConvertTo-InfraPulseHtmlEncoded -Value $message) + '</td>') $null = $builder.AppendLine('</tr>') } $null = $builder.AppendLine('</tbody></table></div></article>') } $null = $builder.AppendLine('<footer>InfraPulse ' + (ConvertTo-InfraPulseHtmlEncoded -Value $script:InfraPulseModuleVersion) + ' | read-only change evidence report</footer>') $null = $builder.AppendLine('</main></body></html>') return $builder.ToString() } |