Private/ConvertTo-EFHtmlReport.ps1

function ConvertTo-EFHtmlReport {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$InputObject,

        [ValidateNotNullOrEmpty()]
        [string]$Title = 'EndpointForge report'
    )

    $encode = {
        param([AllowNull()][object]$Value)

        if ($null -eq $Value) { return '' }
        $text = if ($Value -is [DateTime]) {
            $Value.ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC', [Globalization.CultureInfo]::InvariantCulture)
        }
        elseif ($Value -is [bool]) {
            if ($Value) { 'Yes' } else { 'No' }
        }
        elseif ($Value -is [string] -or $Value.GetType().IsPrimitive -or $Value -is [decimal]) {
            [string]$Value
        }
        else {
            ConvertTo-Json -InputObject (ConvertTo-EFSerializableValue -InputObject $Value) -Depth 12 -Compress
        }
        [Net.WebUtility]::HtmlEncode($text)
    }

    $friendlyStatus = {
        param([AllowNull()][object]$Value)

        switch ([string]$Value) {
            'Healthy' { 'Looks good' }
            'Unhealthy' { 'Needs attention' }
            'Warning' { 'Needs attention' }
            'Critical' { 'Urgent attention' }
            'Incomplete' { 'Could not check everything' }
            'Compliant' { 'Matches the checklist' }
            'NonCompliant' { 'Does not match the checklist' }
            'Complete' { 'All information collected' }
            'Partial' { 'Some information could not be collected' }
            'Failed' { 'Could not collect information' }
            'Error' { 'Could not check' }
            'Unknown' { 'Could not check' }
            'NotApplicable' { 'Not used on this computer' }
            'Ready' { 'Ready' }
            'Limited' { 'Ready with limits' }
            'Blocked' { 'Not available here' }
            'Improved' { 'Improved' }
            'NewIssue' { 'Needs attention now' }
            'CouldNotCheck' { 'Could not check' }
            'Unchanged' { 'Stayed the same' }
            'NowAvailable' { 'Information available now' }
            'ChecklistChanged' { 'Checklist changed' }
            'Changed' { 'Changed and checked' }
            'PartiallyChanged' { 'A value changed, but the fix did not complete' }
            'VerificationFailed' { 'Changed, but the expected result was not confirmed' }
            'EvaluationFailed' { 'Could not check before changing' }
            'WhatIf' { 'Preview only - not changed' }
            'NotRequired' { 'Already matches' }
            'NotRemediable' { 'Needs manual review' }
            'Skipped' { 'Not approved - not changed' }
            'Automatic' { 'EndpointForge can fix' }
            'Manual' { 'You need to review' }
            'NoAction' { 'No change needed' }
            default { [string]$Value }
        }
    }

    $friendlyLabel = {
        param([string]$Name)

        switch ($Name) {
            'OverallStatus' { 'Overall result' }
            'HealthStatus' { 'Computer health' }
            'ComplianceStatus' { 'Checklist result' }
            'DataStatus' { 'Check completeness' }
            'Score' { 'Overall score' }
            'CoveragePercent' { 'Information checked' }
            'IssueCount' { 'Items needing attention' }
            'UnknownCount' { 'Items not fully checked' }
            'TargetCount' { 'Computers requested' }
            'SucceededCount' { 'Computers checked' }
            'FailedCount' { 'Computers not checked' }
            'HealthyCount' { 'Computers that look good' }
            'WarningCount' { 'Computers needing attention' }
            'CriticalCount' { 'Computers needing urgent attention' }
            'IncompleteCount' { 'Computers not fully checked' }
            'AutomaticCount' { 'Fixes EndpointForge can make' }
            'ManualCount' { 'Items for you to review' }
            'BlockedCount' { 'Items that could not be checked' }
            'ChangedCount' { 'Settings changed' }
            'FailureCount' { 'Changes not completed' }
            'Status' { 'Readiness' }
            'AssessmentReady' { 'Can run a check' }
            'FixReady' { 'Can apply supported fixes now' }
            'ControlCount' { 'Checklist items' }
            'ImprovedCount' { 'Items improved' }
            'NewIssueCount' { 'New items needing attention' }
            'CouldNotCheckCount' { 'Items not checked later' }
            'UnchangedCount' { 'Items unchanged' }
            'BeforeScore' { 'Earlier score' }
            'AfterScore' { 'Latest score' }
            'ScoreChange' { 'Score change' }
            'BaselineName' { 'Checklist' }
            'BaselineVersion' { 'Checklist version' }
            'ComputerName' { 'Computer' }
            'BeforeComputerName' { 'Earlier check computer' }
            'AfterComputerName' { 'Latest check computer' }
            'BeforeChecklistName' { 'Earlier checklist' }
            'AfterChecklistName' { 'Latest checklist' }
            'CompletedAtUtc' { 'Check finished' }
            'CreatedAtUtc' { 'Created' }
            'IsRebootPending' { 'Restart waiting' }
            'RebootRequired' { 'Restart may be needed' }
            'CurrentValue' { 'Found now' }
            'DesiredValue' { 'Expected' }
            'BeforeValue' { 'Before' }
            'AfterValue' { 'After' }
            'ControlId' { 'Item ID' }
            'CurrentStatus' { 'Check result' }
            'Action' { 'Who can fix it' }
            'Outcome' { 'Result' }
            default { ($Name -creplace '([a-z0-9])([A-Z])', '$1 $2') }
        }
    }

    $statusClass = {
        param([AllowNull()][object]$Value)

        switch ([string]$Value) {
            { $_ -in @('Healthy', 'Compliant', 'Complete', 'Successful', 'Succeeded', 'Ready') } { 'good'; break }
            { $_ -in @('Changed', 'NotRequired') } { 'good'; break }
            { $_ -in @('Warning', 'Partial', 'Incomplete', 'Manual', 'Limited', 'WhatIf', 'Skipped') } { 'warn'; break }
            { $_ -in @('Critical', 'NonCompliant', 'Failed', 'Error', 'Blocked', 'PartiallyChanged', 'VerificationFailed', 'EvaluationFailed') } { 'bad'; break }
            default { 'neutral' }
        }
    }

    $get = {
        param([AllowNull()][object]$Object, [string]$Name)

        if ($null -eq $Object) { return $null }
        $property = $Object.PSObject.Properties[$Name]
        if ($null -eq $property) { return $null }
        $property.Value
    }

    $builder = [Text.StringBuilder]::new()
    $null = $builder.AppendLine('<!doctype html>')
    $null = $builder.AppendLine('<html lang="en"><head><meta charset="utf-8">')
    $null = $builder.AppendLine('<meta name="viewport" content="width=device-width, initial-scale=1">')
    $null = $builder.AppendLine("<title>$(& $encode $Title)</title>")
    $null = $builder.AppendLine('<style>')
    $null = $builder.AppendLine(':root{color-scheme:light;--ink:#182230;--muted:#5e6b7a;--line:#d9e1ea;--panel:#fff;--bg:#f3f6fa;--brand:#185abd;--good:#147a42;--warn:#9a5b00;--bad:#b42318}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 "Segoe UI",Arial,sans-serif}.wrap{max-width:1120px;margin:auto;padding:32px 20px 64px}header{background:linear-gradient(135deg,#123b6d,#185abd);color:#fff;border-radius:16px;padding:26px 30px;box-shadow:0 8px 24px #183b6d26}h1{margin:0 0 6px;font-size:29px}h2{font-size:21px;margin:0 0 14px}h3{font-size:17px;margin:22px 0 10px}.subtle{color:var(--muted)}header .subtle{color:#e4eefc}.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;margin-top:18px;padding:22px;box-shadow:0 3px 12px #21364d0d}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(155px,1fr));gap:10px;margin:14px 0}.card{border:1px solid var(--line);border-left:5px solid #7890a8;border-radius:9px;padding:12px;background:#fbfcfe}.card.good{border-left-color:var(--good)}.card.warn{border-left-color:var(--warn)}.card.bad{border-left-color:var(--bad)}.label{color:var(--muted);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.value{font-size:17px;font-weight:650;margin-top:3px;overflow-wrap:anywhere}.notice{border-left:4px solid var(--brand);background:#edf5ff;padding:12px 14px;border-radius:6px;margin:14px 0}.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:9px}table{width:100%;border-collapse:collapse;background:#fff}th,td{text-align:left;vertical-align:top;padding:10px 11px;border-bottom:1px solid var(--line);overflow-wrap:anywhere}th{background:#edf2f8;font-size:12px;text-transform:uppercase;letter-spacing:.03em}tr:last-child td{border-bottom:0}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f8fb;border:1px solid var(--line);padding:13px;border-radius:8px;font:12px/1.45 Consolas,monospace}.footer{margin-top:24px;color:var(--muted);font-size:12px}@media print{body{background:#fff}.wrap{max-width:none;padding:0}.panel,header{box-shadow:none;break-inside:avoid}}')
    $null = $builder.AppendLine('</style></head><body><main class="wrap">')
    $null = $builder.AppendLine(('<header><h1>{0}</h1><div class="subtle">A plain-language record created by EndpointForge on {1}.</div></header>' -f
        (& $encode $Title), (& $encode ([DateTime]::UtcNow))))
    $null = $builder.AppendLine('<div class="notice"><strong>Keep this report private.</strong> It can contain computer names, device details, and security findings.</div>')

    $cardProperties = @(
        'Status', 'AssessmentReady', 'FixReady', 'ControlCount',
        'OverallStatus', 'HealthStatus', 'ComplianceStatus', 'DataStatus', 'Score', 'CoveragePercent',
        'IssueCount', 'UnknownCount', 'TargetCount', 'SucceededCount', 'FailedCount', 'HealthyCount',
        'WarningCount', 'CriticalCount', 'IncompleteCount', 'AutomaticCount', 'ManualCount',
        'BlockedCount', 'ChangedCount', 'FailureCount', 'IsRebootPending', 'RebootRequired',
        'BeforeScore', 'AfterScore', 'ScoreChange', 'ImprovedCount', 'NewIssueCount',
        'CouldNotCheckCount', 'UnchangedCount'
    )
    $identityProperties = @(
        'ComputerName', 'BeforeComputerName', 'AfterComputerName', 'BaselineName', 'BaselineVersion',
        'BeforeChecklistName', 'AfterChecklistName', 'CreatedAtUtc', 'CompletedAtUtc'
    )
    $collectionProperties = [ordered]@{
        Findings = @('Title', 'Severity', 'Status', 'Message', 'SuggestedAction')
        Steps    = @('Title', 'Severity', 'CurrentStatus', 'Action', 'CurrentValue', 'DesiredValue', 'WhatWouldChange', 'RecommendedAction')
        Results  = @('ComputerName', 'OverallStatus', 'Score', 'IssueCount', 'Title', 'ControlId', 'BeforeStatus', 'Outcome', 'AfterStatus', 'BeforeValue', 'AfterValue', 'Message', 'RecoveryGuidance')
        FleetResults = @('ComputerName', 'OverallStatus', 'Score', 'IssueCount', 'UnknownCount', 'NextStep')
        Failures = @('ComputerName', 'Message')
        Changes  = @('Title', 'Category', 'BeforeStatus', 'AfterStatus', 'BeforeValue', 'AfterValue', 'Explanation')
        Checks   = @('Name', 'Status', 'PlainLanguage', 'NextStep')
    }

    $reportNumber = 0
    foreach ($item in @($InputObject)) {
        $reportNumber++
        $typeName = if ($null -ne $item -and $item.PSObject.TypeNames.Count -gt 0) {
            [string]$item.PSObject.TypeNames[0]
        }
        else { 'PowerShell object' }
        $heading = if ($InputObject.Count -gt 1) { "Report item $reportNumber" } else { 'Results' }
        $computerName = & $get $item 'ComputerName'
        if (-not [string]::IsNullOrWhiteSpace([string]$computerName)) { $heading += " - $computerName" }

        $null = $builder.AppendLine("<section class=""panel""><h2>$(& $encode $heading)</h2>")
        $summary = & $get $item 'SummaryText'
        if ([string]::IsNullOrWhiteSpace([string]$summary)) {
            $summaryCandidate = & $get $item 'Summary'
            if ($summaryCandidate -is [string] -or $summaryCandidate -is [ValueType]) {
                $summary = $summaryCandidate
            }
        }
        if (-not [string]::IsNullOrWhiteSpace([string]$summary)) {
            $null = $builder.AppendLine("<p>$(& $encode $summary)</p>")
        }

        $identityRows = [Collections.Generic.List[string]]::new()
        foreach ($name in $identityProperties) {
            $value = & $get $item $name
            if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) {
                $identityRows.Add("<tr><th>$(& $encode (& $friendlyLabel $name))</th><td>$(& $encode $value)</td></tr>")
            }
        }
        if ($identityRows.Count -gt 0) {
            $null = $builder.AppendLine('<div class="table-wrap"><table><tbody>')
            foreach ($row in $identityRows) { $null = $builder.AppendLine($row) }
            $null = $builder.AppendLine('</tbody></table></div>')
        }

        $cards = [Collections.Generic.List[string]]::new()
        foreach ($name in $cardProperties) {
            $value = & $get $item $name
            if ($null -eq $value) { continue }
            $displayValue = if ($name -in @('Status', 'OverallStatus', 'HealthStatus', 'ComplianceStatus', 'DataStatus')) {
                & $friendlyStatus $value
            }
            elseif ($name -eq 'CoveragePercent') { "$value%" }
            else { $value }
            $class = & $statusClass $value
            $cards.Add("<div class=""card $class""><div class=""label"">$(& $encode (& $friendlyLabel $name))</div><div class=""value"">$(& $encode $displayValue)</div></div>")
        }
        if ($cards.Count -gt 0) {
            $null = $builder.AppendLine('<div class="cards">')
            foreach ($card in $cards) { $null = $builder.AppendLine($card) }
            $null = $builder.AppendLine('</div>')
        }

        $renderedCollectionCount = 0
        foreach ($collectionName in $collectionProperties.Keys) {
            $collection = @(& $get $item $collectionName)
            if ($collection.Count -eq 0 -or ($collection.Count -eq 1 -and $null -eq $collection[0])) { continue }
            $columns = @($collectionProperties[$collectionName] | Where-Object {
                $columnName = $_
                @($collection | Where-Object { $null -ne $_ -and $null -ne $_.PSObject.Properties[$columnName] }).Count -gt 0
            })
            if ($columns.Count -eq 0) { continue }
            $renderedCollectionCount++

            $null = $builder.AppendLine("<h3>$(& $encode (& $friendlyLabel $collectionName))</h3><div class=""table-wrap""><table><thead><tr>")
            foreach ($column in $columns) {
                $null = $builder.AppendLine("<th>$(& $encode (& $friendlyLabel $column))</th>")
            }
            $null = $builder.AppendLine('</tr></thead><tbody>')
            foreach ($entry in $collection) {
                $null = $builder.AppendLine('<tr>')
                foreach ($column in $columns) {
                    $value = & $get $entry $column
                    if ($column -in @('Status', 'OverallStatus', 'CurrentStatus', 'Action', 'BeforeStatus', 'AfterStatus', 'Outcome', 'Category')) {
                        $value = & $friendlyStatus $value
                    }
                    $null = $builder.AppendLine("<td>$(& $encode $value)</td>")
                }
                $null = $builder.AppendLine('</tr>')
            }
            $null = $builder.AppendLine('</tbody></table></div>')
        }

        $nextStep = & $get $item 'NextStep'
        if (-not [string]::IsNullOrWhiteSpace([string]$nextStep)) {
            $null = $builder.AppendLine("<div class=""notice""><strong>Suggested next step:</strong> $(& $encode $nextStep)</div>")
        }

        if ($null -eq $item) {
            $null = $builder.AppendLine('<p>No report data was supplied.</p>')
        }
        elseif ($cards.Count -eq 0 -and $identityRows.Count -eq 0 -and $renderedCollectionCount -eq 0 -and [string]::IsNullOrWhiteSpace([string]$summary)) {
            $json = ConvertTo-Json -InputObject (ConvertTo-EFSerializableValue -InputObject $item) -Depth 12
            $null = $builder.AppendLine("<h3>Details</h3><pre>$(& $encode $json)</pre>")
        }
        $null = $builder.AppendLine("<div class=""footer"">Technical object type: $(& $encode $typeName)</div></section>")
    }

    $null = $builder.AppendLine('<p class="footer">EndpointForge reports what it could observe at the time of the check. Organization policies and later Windows changes can affect the current state.</p>')
    $null = $builder.AppendLine('</main></body></html>')
    $builder.ToString()
}