Private/Write-CwHtmlReport.ps1

function Write-CwHtmlReport {
    <#
    .SYNOPSIS
        Renders one or more snapshots into a self-contained HTML report string.
    .DESCRIPTION
        No CDN, no external assets: inline CSS, vanilla JS (table sort/filter)
        and hand-built SVG charts. Sections: summary tiles, 90-day expiry
        timeline, per-server certificate tables (grouped by tenant), CRL/CA
        health panel and an optional trend section against a baseline snapshot.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object[]]$Snapshot,

        [object]$Trend,

        [string]$Title = 'cert-watchtower report'
    )

    function Encode([object]$Value) {
        [System.Net.WebUtility]::HtmlEncode([string]$Value)
    }

    $sevColor = @{ OK = '#2e7d32'; WARNING = '#ed6c02'; CRITICAL = '#c62828'; UNKNOWN = '#616161' }
    $now = Get-Date

    $allCerts = foreach ($snap in $Snapshot) {
        foreach ($cert in @($snap.Certificates)) {
            [pscustomobject]@{
                Tenant        = if ($snap.PSObject.Properties['Tenant'] -and $snap.Tenant) { $snap.Tenant } else { 'default' }
                Host          = $snap.Host
                Subject       = $cert.Subject
                Role          = $cert.Role
                NotAfter      = [datetime]$cert.NotAfter
                DaysRemaining = [double]$cert.DaysRemaining
                Severity      = $cert.Severity
                Thumbprint    = $cert.Thumbprint
            }
        }
    }
    $allCerts = @($allCerts)

    $critCount = @($allCerts | Where-Object Severity -eq 'CRITICAL').Count
    $warnCount = @($allCerts | Where-Object Severity -eq 'WARNING').Count
    $next = $allCerts | Sort-Object DaysRemaining | Select-Object -First 1

    $allCrls = @($Snapshot | ForEach-Object { @($_.Crls) } | Where-Object { $_ })
    $crlUnhealthy = @($allCrls | Where-Object { $_.Severity -in 'WARNING', 'CRITICAL' }).Count
    $crlLight = if ($allCrls.Count -eq 0) { @('UNKNOWN', 'no CRL data') }
    elseif ($crlUnhealthy -eq 0) { @('OK', "$($allCrls.Count) CRLs fresh") }
    else { @('CRITICAL', "$crlUnhealthy of $($allCrls.Count) CRLs unhealthy") }

    $allCas = @($Snapshot | ForEach-Object { $_.CaHealth } | Where-Object { $_ -and $_.IsCa })
    $caUnhealthy = @($allCas | Where-Object { $_.Severity -in 'WARNING', 'CRITICAL' }).Count
    $caLight = if ($allCas.Count -eq 0) { @('UNKNOWN', 'no CA in scope') }
    elseif ($caUnhealthy -eq 0) { @('OK', "$($allCas.Count) CA(s) healthy") }
    else { @('CRITICAL', "$caUnhealthy of $($allCas.Count) CA(s) unhealthy") }

    $score = if ($critCount -gt 0 -or $crlLight[0] -eq 'CRITICAL' -or $caLight[0] -eq 'CRITICAL') { 'CRITICAL' }
    elseif ($warnCount -gt 0) { 'WARNING' } else { 'OK' }

    # --- SVG timeline: expiries in the next 90 days, one bar per week --------
    $weeks = for ($w = 0; $w -lt 13; $w++) {
        $lo = $w * 7; $hi = $lo + 7
        $bucket = @($allCerts | Where-Object { $_.DaysRemaining -ge $lo -and $_.DaysRemaining -lt $hi })
        [pscustomobject]@{
            Label = "W$($w + 1)"
            Crit  = @($bucket | Where-Object Severity -eq 'CRITICAL').Count
            Warn  = @($bucket | Where-Object Severity -eq 'WARNING').Count
            Ok    = @($bucket | Where-Object Severity -eq 'OK').Count
        }
    }
    $maxBar = (@($weeks | ForEach-Object { $_.Crit + $_.Warn + $_.Ok }) | Measure-Object -Maximum).Maximum
    if (-not $maxBar) { $maxBar = 1 }
    $svgBars = New-Object System.Text.StringBuilder
    $chartH = 160; $barW = 44; $gap = 10
    for ($w = 0; $w -lt 13; $w++) {
        $x = 30 + $w * ($barW + $gap)
        $y = $chartH
        foreach ($part in @(@('Crit', 'CRITICAL'), @('Warn', 'WARNING'), @('Ok', 'OK'))) {
            $count = $weeks[$w].($part[0])
            if ($count -gt 0) {
                $h = [math]::Max(3, [math]::Round($count / $maxBar * ($chartH - 20)))
                $y -= $h
                [void]$svgBars.Append("<rect x='$x' y='$y' width='$barW' height='$h' fill='$($sevColor[$part[1]])' rx='2'><title>$count $($part[1]) in week $($w + 1)</title></rect>")
            }
        }
        $total = $weeks[$w].Crit + $weeks[$w].Warn + $weeks[$w].Ok
        if ($total -gt 0) {
            [void]$svgBars.Append("<text x='$($x + $barW / 2)' y='$($y - 5)' text-anchor='middle' class='barlabel'>$total</text>")
        }
        [void]$svgBars.Append("<text x='$($x + $barW / 2)' y='$($chartH + 16)' text-anchor='middle' class='axis'>$($weeks[$w].Label)</text>")
    }

    # --- per-tenant / per-server certificate tables --------------------------
    $tables = New-Object System.Text.StringBuilder
    $tenantGroups = $Snapshot | Group-Object { if ($_.PSObject.Properties['Tenant'] -and $_.Tenant) { $_.Tenant } else { 'default' } } | Sort-Object Name
    foreach ($tenant in $tenantGroups) {
        [void]$tables.Append("<section class='tenant'><h2>Tenant: $(Encode $tenant.Name)</h2>")
        foreach ($snap in ($tenant.Group | Sort-Object Host)) {
            $certs = @($snap.Certificates)
            [void]$tables.Append("<details open><summary><strong>$(Encode $snap.Host)</strong> &middot; $($certs.Count) certificates &middot; scanned $(Encode $snap.Timestamp)</summary>")
            [void]$tables.Append("<input type='text' class='filter' placeholder='Filter...' onkeyup='cwFilter(this)'>")
            [void]$tables.Append("<table class='certs sortable'><thead><tr><th onclick='cwSort(this,0)'>Severity</th><th onclick='cwSort(this,1)'>Role</th><th onclick='cwSort(this,2)'>Subject</th><th onclick='cwSort(this,3)' data-num='1'>Days left</th><th onclick='cwSort(this,4)'>Expires</th><th onclick='cwSort(this,5)'>Thumbprint</th></tr></thead><tbody>")
            foreach ($cert in ($certs | Sort-Object DaysRemaining)) {
                $sev = if ($cert.Severity) { $cert.Severity } else { 'OK' }
                $color = $sevColor[[string]$sev]
                $notAfter = ([datetime]$cert.NotAfter).ToString('yyyy-MM-dd')
                [void]$tables.Append("<tr><td><span class='pill' style='background:$color'>$(Encode $sev)</span></td><td>$(Encode $cert.Role)</td><td>$(Encode $cert.Subject)</td><td data-v='$($cert.DaysRemaining)'>$([math]::Round([double]$cert.DaysRemaining, 1))</td><td>$notAfter</td><td class='mono'>$(Encode $cert.Thumbprint)</td></tr>")
            }
            [void]$tables.Append('</tbody></table></details>')
        }
        [void]$tables.Append('</section>')
    }

    # --- CRL / CA panel ------------------------------------------------------
    $crlPanel = New-Object System.Text.StringBuilder
    foreach ($snap in $Snapshot) {
        foreach ($crl in @($snap.Crls)) {
            if (-not $crl) { continue }
            $sev = if ($crl.Severity) { $crl.Severity } else { 'UNKNOWN' }
            $cdp = @($crl.CdpResults) | ForEach-Object { "$(Encode $_.Url) $(if ($_.Reachable) { 'OK' } else { 'FAIL' })" }
            [void]$crlPanel.Append("<tr><td><span class='pill' style='background:$($sevColor[[string]$sev])'>$(Encode $sev)</span></td><td>$(Encode $snap.Host)</td><td>$(Encode $crl.Type)</td><td>$(Encode $crl.Issuer)</td><td>$(Encode $crl.NextUpdate)</td><td>$(if ($null -ne $crl.HoursRemaining) { [math]::Round([double]$crl.HoursRemaining, 1) })</td><td>$($cdp -join '<br>')</td><td>$(Encode $crl.SignatureValid)</td></tr>")
        }
    }
    $caPanel = New-Object System.Text.StringBuilder
    foreach ($snap in $Snapshot) {
        $ca = $snap.CaHealth
        if (-not ($ca -and $ca.IsCa)) { continue }
        $sev = if ($ca.Severity) { $ca.Severity } else { 'UNKNOWN' }
        [void]$caPanel.Append("<tr><td><span class='pill' style='background:$($sevColor[[string]$sev])'>$(Encode $sev)</span></td><td>$(Encode $snap.Host)</td><td>$(Encode $ca.CaName)</td><td>$(Encode $ca.CaType)</td><td>$(Encode $ca.ServiceStatus)</td><td>$(Encode $ca.PendingRequests)</td><td>$(Encode $ca.FailedRequests24h)</td><td>$(Encode $ca.CaCertNotAfter)</td></tr>")
    }

    # --- trend section -------------------------------------------------------
    $trendHtml = ''
    if ($Trend) {
        $sb = New-Object System.Text.StringBuilder
        [void]$sb.Append("<section><h2>Trend vs. baseline</h2>")
        foreach ($block in @(
                @('New certificates', $Trend.NewCertificates),
                @('Removed certificates', $Trend.RemovedCertificates),
                @('Approaching expiry (severity escalated)', $Trend.Approaching))) {
            $items = @($block[1])
            [void]$sb.Append("<h3>$($block[0]) ($($items.Count))</h3>")
            if ($items.Count -gt 0) {
                [void]$sb.Append('<ul>')
                foreach ($item in $items) {
                    $extra = if ($item.PSObject.Properties['DaysRemaining']) { " &middot; $([math]::Round([double]$item.DaysRemaining, 1))d left" } else { '' }
                    [void]$sb.Append("<li><code>$(Encode $item.Host)</code> $(Encode $item.Role) $(Encode $item.Subject)$extra</li>")
                }
                [void]$sb.Append('</ul>')
            }
            else {
                [void]$sb.Append("<p class='muted'>none</p>")
            }
        }
        [void]$sb.Append('</section>')
        $trendHtml = $sb.ToString()
    }

    $style = @'
<style>
:root{--fg:#1c2430;--muted:#68737f;--bg:#f4f6f8;--card:#ffffff;--line:#dde3e9}
*{box-sizing:border-box}body{font-family:'Segoe UI',system-ui,sans-serif;color:var(--fg);background:var(--bg);margin:0;padding:24px}
h1{margin:0 0 4px;font-size:26px}h2{font-size:19px;margin:28px 0 10px}h3{font-size:15px;margin:14px 0 6px}
.sub{color:var(--muted);margin-bottom:20px}.muted{color:var(--muted)}
.tiles{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}
.tile{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 18px;min-width:150px;flex:1}
.tile .v{font-size:24px;font-weight:600}.tile .l{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.pill{color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;font-weight:600;display:inline-block}
table{border-collapse:collapse;width:100%;background:var(--card);border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:13px;margin:8px 0 18px}
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid var(--line)}th{background:#eef1f4;cursor:pointer;user-select:none;white-space:nowrap}
tr:last-child td{border-bottom:none}.mono{font-family:Consolas,monospace;font-size:12px}
.filter{margin:8px 0 2px;padding:6px 10px;border:1px solid var(--line);border-radius:6px;width:260px}
details{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 14px;margin:10px 0}
summary{cursor:pointer}section.tenant{margin-top:10px}
svg{background:var(--card);border:1px solid var(--line);border-radius:10px}
.axis{font-size:11px;fill:#68737f}.barlabel{font-size:11px;fill:#1c2430;font-weight:600}
.legend span{display:inline-block;margin-right:14px;font-size:12px}.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px}
</style>
'@


    $script = @'
<script>
function cwFilter(inp){
  var q=inp.value.toLowerCase(), tb=inp.nextElementSibling.tBodies[0];
  for(var r of tb.rows){r.style.display=r.textContent.toLowerCase().indexOf(q)>=0?'':'none';}
}
function cwSort(th,idx){
  var table=th.closest('table'), tb=table.tBodies[0], rows=Array.from(tb.rows);
  var num=th.hasAttribute('data-num'), asc=th.dataset.asc!=='1';
  table.querySelectorAll('th').forEach(function(h){h.dataset.asc='';});
  th.dataset.asc=asc?'1':'0';
  rows.sort(function(a,b){
    var x=a.cells[idx], y=b.cells[idx];
    var xv=x.dataset.v!==undefined?parseFloat(x.dataset.v):x.textContent.trim();
    var yv=y.dataset.v!==undefined?parseFloat(y.dataset.v):y.textContent.trim();
    if(num||typeof xv==='number'){xv=parseFloat(xv)||0;yv=parseFloat(yv)||0;return asc?xv-yv:yv-xv;}
    return asc?String(xv).localeCompare(String(yv)):String(yv).localeCompare(String(xv));
  });
  rows.forEach(function(r){tb.appendChild(r);});
}
</script>
'@


    $nextTile = if ($next) {
        "$([math]::Round($next.DaysRemaining, 1))d<div class='l'>$(Encode $next.Role) on $(Encode $next.Host)</div>"
    }
    else { '&ndash;' }

    $html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$(Encode $Title)</title>
$style
</head>
<body>
<h1>$(Encode $Title)</h1>
<div class="sub">Generated $($now.ToString('yyyy-MM-dd HH:mm:ss')) &middot; $($Snapshot.Count) server(s) &middot; $($allCerts.Count) certificates &middot; cert-watchtower</div>
<div class="tiles">
  <div class="tile"><div class="l">Overall</div><div class="v"><span class="pill" style="background:$($sevColor[$score])">$score</span></div></div>
  <div class="tile"><div class="l">Critical</div><div class="v" style="color:$($sevColor['CRITICAL'])">$critCount</div></div>
  <div class="tile"><div class="l">Warning</div><div class="v" style="color:$($sevColor['WARNING'])">$warnCount</div></div>
  <div class="tile"><div class="l">Next expiry</div><div class="v">$nextTile</div></div>
  <div class="tile"><div class="l">CRL health</div><div class="v"><span class="pill" style="background:$($sevColor[$crlLight[0]])">$($crlLight[0])</span></div><div class="l">$(Encode $crlLight[1])</div></div>
  <div class="tile"><div class="l">CA health</div><div class="v"><span class="pill" style="background:$($sevColor[$caLight[0]])">$($caLight[0])</span></div><div class="l">$(Encode $caLight[1])</div></div>
</div>
<h2>Expiry timeline &ndash; next 90 days</h2>
<div class="legend"><span><i style="background:$($sevColor['CRITICAL'])"></i>CRITICAL</span><span><i style="background:$($sevColor['WARNING'])"></i>WARNING</span><span><i style="background:$($sevColor['OK'])"></i>OK</span></div>
<svg viewBox="0 0 740 190" width="740" height="190" role="img" aria-label="Certificate expiries per week">$($svgBars.ToString())</svg>
$trendHtml
$($tables.ToString())
<h2>CRL health</h2>
<table><thead><tr><th>Severity</th><th>Host</th><th>Type</th><th>Issuer</th><th>NextUpdate</th><th>Hours left</th><th>CDP reachability</th><th>Signature valid</th></tr></thead>
<tbody>$(if ($crlPanel.Length) { $crlPanel.ToString() } else { "<tr><td colspan='8' class='muted'>no CRL data collected</td></tr>" })</tbody></table>
<h2>ADCS CA health</h2>
<table><thead><tr><th>Severity</th><th>Host</th><th>CA</th><th>Type</th><th>CertSvc</th><th>Pending</th><th>Failed 24h</th><th>CA cert expires</th></tr></thead>
<tbody>$(if ($caPanel.Length) { $caPanel.ToString() } else { "<tr><td colspan='8' class='muted'>no CA in scope</td></tr>" })</tbody></table>
$script
</body>
</html>
"@


    $html
}