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 and hand-built SVG. Light/dark theme via prefers-color-scheme, validated status palette, icon+label severity badges (never color alone), hover tooltips on the expiry timeline, sortable/filterable tables with remaining-lifetime meters, print styles. Sections: summary tiles, 90-day expiry timeline, optional trend vs. baseline, per-server certificate tables (grouped by tenant), CRL and CA health panels. #> [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) } function FormatDate([object]$Value) { if ($null -eq $Value -or $Value -eq '') { return '' } try { ([datetime]$Value).ToString('yyyy-MM-dd HH:mm') } catch { [string]$Value } } function Prop([object]$Object, [string]$Name) { # StrictMode-safe property access; older snapshot JSONs may lack fields if ($Object.PSObject.Properties[$Name]) { $Object.$Name } else { $null } } # 12px stroke icons (feather-style), inherit currentColor from the badge class $icons = @{ OK = "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M20 6L9 17l-5-5'/></svg>" WARNING = "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><path d='M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'/><line x1='12' y1='9' x2='12' y2='13'/><line x1='12' y1='17' x2='12.01' y2='17'/></svg>" CRITICAL = "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='10'/><line x1='15' y1='9' x2='9' y2='15'/><line x1='9' y1='9' x2='15' y2='15'/></svg>" UNKNOWN = "<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='10'/><path d='M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3'/><line x1='12' y1='17' x2='12.01' y2='17'/></svg>" } function Badge([object]$Severity) { $sev = if ($Severity) { [string]$Severity } else { 'UNKNOWN' } if ($sev -notin 'OK', 'WARNING', 'CRITICAL', 'UNKNOWN') { $sev = 'UNKNOWN' } $cls = @{ OK = 'b-ok'; WARNING = 'b-warn'; CRITICAL = 'b-crit'; UNKNOWN = 'b-unk' }[$sev] "<span class='badge $cls'><i class='ic'>$($icons[$sev])</i>$sev</span>" } function Dot([object]$Severity) { $cls = @{ OK = 'd-ok'; WARNING = 'd-warn'; CRITICAL = 'd-crit'; UNKNOWN = 'd-unk' }[[string]$Severity] if (-not $cls) { $cls = 'd-unk' } "<i class='dot $cls'></i>" } $sevRank = @{ CRITICAL = 3; WARNING = 2; UNKNOWN = 1; OK = 0 } $now = Get-Date $inv = [cultureinfo]::InvariantCulture # ---------------------------------------------------------------- data --- $allCerts = foreach ($snap in $Snapshot) { foreach ($cert in @($snap.Certificates)) { [pscustomobject]@{ Tenant = if ((Prop $snap 'Tenant')) { $snap.Tenant } else { 'default' } Host = $snap.Host Subject = $cert.Subject Role = $cert.Role NotAfter = [datetime]$cert.NotAfter DaysRemaining = [double]$cert.DaysRemaining Severity = if ($cert.Severity) { [string]$cert.Severity } else { 'OK' } } } } $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) 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) unhealthy") } $score = if ($critCount -gt 0 -or $crlLight[0] -eq 'CRITICAL' -or $caLight[0] -eq 'CRITICAL') { 'CRITICAL' } elseif ($warnCount -gt 0 -or $crlLight[0] -eq 'WARNING' -or $caLight[0] -eq 'WARNING') { 'WARNING' } else { 'OK' } # ------------------------------------------------- SVG expiry timeline --- # Stacked weekly columns, CRITICAL anchored at the baseline. Mark specs: # <=24px columns, 4px rounded cap on the top segment, 2px surface gaps. $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]@{ Start = $now.AddDays($lo) Crit = @($bucket | Where-Object Severity -eq 'CRITICAL').Count Warn = @($bucket | Where-Object Severity -eq 'WARNING').Count Ok = @($bucket | Where-Object Severity -eq 'OK').Count } } $maxTotal = (@($weeks | ForEach-Object { $_.Crit + $_.Warn + $_.Ok }) | Measure-Object -Maximum).Maximum if (-not $maxTotal) { $maxTotal = 0 } $svgW = 780; $svgH = 216; $padL = 34; $padR = 8; $padT = 14; $padB = 26 $innerH = $svgH - $padT - $padB $baseY = $svgH - $padB $bandW = ($svgW - $padL - $padR) / 13 $barW = [math]::Min(24, [math]::Floor($bandW - 16)) # nice y-scale: smallest nice step giving <= 4 gridlines $step = 1 foreach ($candidate in 1, 2, 5, 10, 20, 25, 50, 100, 200, 500) { if ([math]::Ceiling(([math]::Max($maxTotal, 1)) / $candidate) -le 4) { $step = $candidate; break } } $niceMax = [math]::Max($step, [math]::Ceiling(([math]::Max($maxTotal, 1)) / $step) * $step) $svg = New-Object System.Text.StringBuilder for ($tick = 0; $tick -le $niceMax; $tick += $step) { $y = [math]::Round($baseY - ($tick / $niceMax) * $innerH, 1) $lineCls = if ($tick -eq 0) { 'axisline' } else { 'gridline' } [void]$svg.Append("<line x1='$padL' y1='$y' x2='$($svgW - $padR)' y2='$y' class='$lineCls'/>") [void]$svg.Append("<text x='$($padL - 7)' y='$($y + 3.5)' text-anchor='end' class='axis-t tnum'>$tick</text>") } for ($w = 0; $w -lt 13; $w++) { $x = [math]::Round($padL + $w * $bandW + ($bandW - $barW) / 2, 1) $centerX = [math]::Round($padL + $w * $bandW + $bandW / 2, 1) $weekLabel = $weeks[$w].Start.ToString('d MMM', $inv) if ($w % 2 -eq 0 -or $bandW -gt 52) { [void]$svg.Append("<text x='$centerX' y='$($svgH - 8)' text-anchor='middle' class='axis-t'>$weekLabel</text>") } $segments = [System.Collections.Generic.List[object]]::new() if ($weeks[$w].Crit -gt 0) { $segments.Add(@($weeks[$w].Crit, 'seg-crit')) } if ($weeks[$w].Warn -gt 0) { $segments.Add(@($weeks[$w].Warn, 'seg-warn')) } if ($weeks[$w].Ok -gt 0) { $segments.Add(@($weeks[$w].Ok, 'seg-ok')) } $yCursor = [double]$baseY for ($s = 0; $s -lt $segments.Count; $s++) { $count = $segments[$s][0]; $cls = $segments[$s][1] $segH = ($count / $niceMax) * $innerH $isTop = ($s -eq @($segments).Count - 1) $drawH = if ($isTop) { $segH } else { [math]::Max(2, $segH - 2) } # 2px surface gap $yTop = $yCursor - $segH if ($isTop -and $drawH -ge 6) { # 4px rounded data-end, square at the baseline side $r = 4 $x2 = $x + $barW [void]$svg.Append(("<path class='$cls' d='M{0},{1} L{0},{2} Q{0},{3} {4},{3} L{5},{3} Q{6},{3} {6},{2} L{6},{1} Z'/>" -f ` [math]::Round($x, 1), [math]::Round($yCursor, 1), [math]::Round($yTop + $r, 1), [math]::Round($yTop, 1), [math]::Round($x + $r, 1), [math]::Round($x2 - $r, 1), [math]::Round($x2, 1))) } else { [void]$svg.Append("<rect class='$cls' x='$([math]::Round($x, 1))' y='$([math]::Round($yCursor - $segH, 1))' width='$barW' height='$([math]::Round($drawH, 1))'/>") } $yCursor -= $segH } $total = $weeks[$w].Crit + $weeks[$w].Warn + $weeks[$w].Ok if ($total -gt 0) { [void]$svg.Append("<text x='$centerX' y='$([math]::Round($yCursor - 6, 1))' text-anchor='middle' class='bar-t tnum'>$total</text>") } # transparent full-band hit target for the hover tooltip $rangeLabel = "$($weeks[$w].Start.ToString('d MMM', $inv)) – $($weeks[$w].Start.AddDays(6).ToString('d MMM', $inv))" [void]$svg.Append("<rect class='hit' x='$([math]::Round($padL + $w * $bandW, 1))' y='$padT' width='$([math]::Round($bandW, 1))' height='$($innerH)' " + "data-label='$rangeLabel' data-crit='$($weeks[$w].Crit)' data-warn='$($weeks[$w].Warn)' data-ok='$($weeks[$w].Ok)'/>") } $chartEmpty = '' if ($maxTotal -eq 0) { $chartEmpty = "<div class='chart-empty'>No certificate expiries in the next 90 days</div>" } # ------------------------------------------------------- trend section --- $trendHtml = '' if ($Trend) { $sb = New-Object System.Text.StringBuilder [void]$sb.Append("<section class='card'><div class='card-h'><h2>Trend vs. baseline</h2></div><div class='trend-grid'>") foreach ($block in @( @('New certificates', $Trend.NewCertificates), @('Removed certificates', $Trend.RemovedCertificates), @('Approaching expiry', $Trend.Approaching))) { $items = @($block[1]) [void]$sb.Append("<div class='trend-col'><div class='trend-h'>$($block[0]) <span class='chip tnum'>$($items.Count)</span></div>") if ($items.Count -gt 0) { [void]$sb.Append("<ul class='trend-list'>") foreach ($item in ($items | Select-Object -First 12)) { $days = if ($null -ne (Prop $item 'DaysRemaining')) { " · $([math]::Round([double]$item.DaysRemaining, 1))d left" } else { '' } [void]$sb.Append("<li>$(Dot (Prop $item 'Severity'))<span class='trend-host'>$(Encode $item.Host)</span> $(Encode $item.Role) · $(Encode $item.Subject)$days</li>") } if ($items.Count -gt 12) { [void]$sb.Append("<li class='muted'>+ $($items.Count - 12) more</li>") } [void]$sb.Append('</ul>') } else { [void]$sb.Append("<div class='empty-s'>none</div>") } [void]$sb.Append('</div>') } [void]$sb.Append('</div></section>') $trendHtml = $sb.ToString() } # -------------------------------------- per-tenant / per-server tables --- $tables = New-Object System.Text.StringBuilder $tenantGroups = $Snapshot | Group-Object { if ((Prop $_ 'Tenant')) { $_.Tenant } else { 'default' } } | Sort-Object Name foreach ($tenant in $tenantGroups) { if ($tenantGroups.Count -gt 1 -or $tenant.Name -ne 'default') { [void]$tables.Append("<h2 class='tenant-h'>Tenant · $(Encode $tenant.Name)</h2>") } foreach ($snap in ($tenant.Group | Sort-Object Host)) { $certs = @($snap.Certificates) $hostWorst = 'OK' foreach ($cert in $certs) { $certSev = if ($cert.Severity) { [string]$cert.Severity } else { 'OK' } if ($sevRank[$certSev] -gt $sevRank[$hostWorst]) { $hostWorst = $certSev } } $hostNext = $certs | Sort-Object { [double]$_.DaysRemaining } | Select-Object -First 1 [void]$tables.Append("<section class='card'><details open><summary><span class='srv'>$(Encode $snap.Host)</span>") [void]$tables.Append("<span class='chip tnum'>$($certs.Count) certs</span>") if ($hostNext) { [void]$tables.Append("<span class='chip'>next expiry <b class='tnum'>$([math]::Floor([double]$hostNext.DaysRemaining))d</b></span>") } [void]$tables.Append("<span class='chip muted'>scanned $(FormatDate $snap.Timestamp)</span><span class='spacer'></span>$(Badge $hostWorst)</summary>") [void]$tables.Append("<div class='table-tools'><label class='search'><svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.2' stroke-linecap='round'><circle cx='11' cy='11' r='7'/><line x1='21' y1='21' x2='16.5' y2='16.5'/></svg><input type='text' placeholder='Filter certificates…' onkeyup='cwFilter(this)'></label></div>") [void]$tables.Append("<div class='twrap'><table class='certs'><thead><tr>" + "<th onclick='cwSort(this,0)'>Severity</th><th onclick='cwSort(this,1)'>Role</th><th onclick='cwSort(this,2)'>Name / Template</th>" + "<th onclick='cwSort(this,3)'>Subject</th><th onclick='cwSort(this,4)'>SAN</th><th onclick='cwSort(this,5)'>Issued by</th>" + "<th onclick='cwSort(this,6)' data-num='1'>Days left</th><th onclick='cwSort(this,7)'>Expires</th><th onclick='cwSort(this,8)'>Thumbprint</th>" + '</tr></thead><tbody>') foreach ($cert in ($certs | Sort-Object { [double]$_.DaysRemaining })) { $sev = if ($cert.Severity) { [string]$cert.Severity } else { 'OK' } $notAfter = ([datetime]$cert.NotAfter).ToString('yyyy-MM-dd') $days = [double]$cert.DaysRemaining $name = Prop $cert 'FriendlyName' if (-not $name) { $name = Prop $cert 'Template' } $nameTitle = @( $(if (Prop $cert 'Template') { "Template: $(Prop $cert 'Template')" }) $(if (Prop $cert 'EkuNames') { "EKU: $(@(Prop $cert 'EkuNames') -join ', ')" }) $(if (Prop $cert 'KeyAlgorithm') { "Key: $(Prop $cert 'KeyAlgorithm')" }) $(if ((Prop $cert 'HasPrivateKey') -eq $false) { 'No private key' }) ) | Where-Object { $_ } $nameCell = if ($name) { Encode $name } else { "<span class='muted'>—</span>" } $san = @(Prop $cert 'SanDnsNames') $sanCell = if ($san.Count -gt 0) { $shown = @($san | Select-Object -First 2) $more = if ($san.Count -gt 2) { " <span class='muted'>+$($san.Count - 2)</span>" } else { '' } "$(Encode ($shown -join ', '))$more" } else { "<span class='muted'>—</span>" } $issuer = [string]$cert.Issuer $issuerCn = if ($issuer -match '(?:^|,\s*)CN=([^,]+)') { $Matches[1] } else { $issuer } $thumb = [string]$cert.Thumbprint $thumbShort = if ($thumb.Length -gt 16) { "$($thumb.Substring(0, 8))…$($thumb.Substring($thumb.Length - 6))" } else { Encode $thumb } # remaining-lifetime meter vs the 90-day window; fill = status, track = tint $meterPct = [math]::Max(4, [math]::Min(100, [math]::Round($days / 90 * 100))) if ($days -le 0) { $meterPct = 100 } $meterCls = @{ OK = 'm-ok'; WARNING = 'm-warn'; CRITICAL = 'm-crit'; UNKNOWN = 'm-unk' }[$sev] [void]$tables.Append("<tr><td>$(Badge $sev)</td>" + "<td>$(Encode $cert.Role)</td>" + "<td title='$(Encode ($nameTitle -join ' | '))'>$nameCell</td>" + "<td class='subj'>$(Encode $cert.Subject)</td>" + "<td title='$(Encode ($san -join ', '))'>$sanCell</td>" + "<td title='$(Encode $issuer)'>$(Encode $issuerCn)</td>" + "<td data-v='$days' class='tnum'><div class='days'><span>$([math]::Round($days, 1))</span><span class='meter $meterCls'><i style='width:$meterPct%'></i></span></div></td>" + "<td class='tnum'>$notAfter</td>" + "<td class='mono' title='$(Encode $thumb)'>$thumbShort</td></tr>") } [void]$tables.Append('</tbody></table></div></details></section>') } } # -------------------------------------------------- CRL / CA panels ------ $crlRows = New-Object System.Text.StringBuilder foreach ($snap in $Snapshot) { foreach ($crl in @($snap.Crls)) { if (-not $crl) { continue } $cdp = @(Prop $crl 'CdpResults') | ForEach-Object { $state = if ($_.Reachable) { "<span class='reach-ok'>reachable</span>" } else { "<span class='reach-fail'>unreachable</span>" } "<span class='mono cdp'>$(Encode $_.Url)</span> $state" } $sigCell = switch ([string](Prop $crl 'SignatureValid')) { 'True' { "<span class='reach-ok'>valid</span>" } 'False' { "<span class='reach-fail'>INVALID</span>" } default { "<span class='muted'>n/a</span>" } } $hours = Prop $crl 'HoursRemaining' [void]$crlRows.Append("<tr><td>$(Badge (Prop $crl 'Severity'))</td><td>$(Encode $snap.Host)</td><td>$(Encode (Prop $crl 'Type'))</td>" + "<td class='subj'>$(Encode (Prop $crl 'Issuer'))</td><td class='tnum'>$(FormatDate (Prop $crl 'NextUpdate'))</td>" + "<td class='tnum'>$(if ($null -ne $hours) { [math]::Round([double]$hours, 1) })</td>" + "<td>$($cdp -join '<br>')</td><td>$sigCell</td></tr>") } } $caRows = New-Object System.Text.StringBuilder foreach ($snap in $Snapshot) { $ca = $snap.CaHealth if (-not ($ca -and $ca.IsCa)) { continue } [void]$caRows.Append("<tr><td>$(Badge (Prop $ca 'Severity'))</td><td>$(Encode $snap.Host)</td><td>$(Encode (Prop $ca 'CaName'))</td>" + "<td>$(Encode (Prop $ca 'CaType'))</td><td>$(Encode (Prop $ca 'ServiceStatus'))</td><td class='tnum'>$(Encode (Prop $ca 'PendingRequests'))</td>" + "<td class='tnum'>$(Encode (Prop $ca 'FailedRequests24h'))</td><td class='tnum'>$(FormatDate (Prop $ca 'CaCertNotAfter'))</td></tr>") } $emptyRow = "<tr><td colspan='8'><div class='empty'><svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='10'/><line x1='8' y1='12' x2='16' y2='12'/></svg>{0}</div></td></tr>" # --------------------------------------------------------------- style --- $style = @' <style> :root{ color-scheme: light; --page:#f4f4f1; --card:#fcfcfb; --ink:#0b0b0b; --ink-2:#52514e; --muted:#898781; --grid:#e1e0d9; --axis:#c3c2b7; --line:rgba(11,11,11,.10); --line-soft:rgba(11,11,11,.06); --ok:#0ca30c; --warn:#b97c00; --crit:#d03b3b; --unk:#898781; --ok-m:#0ca30c; --warn-m:#fab219; --crit-m:#d03b3b; --unk-m:#898781; --ok-t:rgba(12,163,12,.09); --warn-t:rgba(250,178,25,.13); --crit-t:rgba(208,59,59,.09); --unk-t:rgba(137,135,129,.10); --ok-b:rgba(12,163,12,.30); --warn-b:rgba(185,124,0,.35); --crit-b:rgba(208,59,59,.30); --unk-b:rgba(137,135,129,.30); --accent:#2a78d6; --shadow:0 1px 2px rgba(11,11,11,.04),0 4px 16px rgba(11,11,11,.04); --hover:rgba(11,11,11,.028); } @media (prefers-color-scheme: dark){ :root{ color-scheme: dark; --page:#0d0d0d; --card:#1a1a19; --ink:#ffffff; --ink-2:#c3c2b7; --muted:#898781; --grid:#2c2c2a; --axis:#383835; --line:rgba(255,255,255,.10); --line-soft:rgba(255,255,255,.06); --warn:#fab219; --shadow:0 1px 2px rgba(0,0,0,.3),0 6px 20px rgba(0,0,0,.25); --hover:rgba(255,255,255,.035); } } *{box-sizing:border-box} body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;color:var(--ink);background:var(--page);margin:0;padding:28px 32px 48px;font-size:14px;line-height:1.45} .wrap{max-width:1240px;margin:0 auto} h1{margin:0;font-size:21px;font-weight:650;letter-spacing:-.01em} h2{font-size:15px;font-weight:650;margin:0;letter-spacing:-.005em} .tenant-h{font-size:13px;font-weight:650;color:var(--ink-2);text-transform:uppercase;letter-spacing:.07em;margin:30px 4px 0} .muted{color:var(--muted)} .tnum{font-variant-numeric:tabular-nums} /* header */ .hdr{display:flex;align-items:center;gap:14px;margin-bottom:6px} .mark{width:38px;height:38px;border-radius:10px;background:linear-gradient(145deg,#2a78d6,#1c5cab);display:flex;align-items:center;justify-content:center;box-shadow:var(--shadow);flex:none} .mark svg{width:22px;height:22px;stroke:#fff} .hdr-sub{color:var(--muted);font-size:12.5px;margin-top:2px;display:flex;gap:8px;flex-wrap:wrap;align-items:center} .hdr-sub b{color:var(--ink-2);font-weight:600} .hdr-sub .sep{opacity:.5} /* tiles */ .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));gap:14px;margin:22px 0 26px} .tile{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 17px 14px;box-shadow:var(--shadow);position:relative;overflow:hidden} .tile.accent-crit::before,.tile.accent-warn::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px} .tile.accent-crit::before{background:var(--crit-m)} .tile.accent-warn::before{background:var(--warn-m)} .tile .l{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);font-weight:650} .tile .v{font-size:29px;font-weight:650;margin-top:7px;letter-spacing:-.02em;display:flex;align-items:center;gap:9px} .tile .s{font-size:11.5px;color:var(--muted);margin-top:3px} /* badges & dots */ .badge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px 3px 8px;border-radius:999px;font-size:11px;font-weight:650;letter-spacing:.04em;border:1px solid;color:var(--ink)} .badge .ic{display:inline-flex}.badge .ic svg{width:12px;height:12px} .b-ok{background:var(--ok-t);border-color:var(--ok-b)}.b-ok .ic{color:var(--ok-m)} .b-warn{background:var(--warn-t);border-color:var(--warn-b)}.b-warn .ic{color:var(--warn)} .b-crit{background:var(--crit-t);border-color:var(--crit-b)}.b-crit .ic{color:var(--crit-m)} .b-unk{background:var(--unk-t);border-color:var(--unk-b)}.b-unk .ic{color:var(--unk-m)} .badge.lg{font-size:13px;padding:5px 14px 5px 11px}.badge.lg .ic svg{width:15px;height:15px} .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;flex:none} .d-ok{background:var(--ok-m)}.d-warn{background:var(--warn-m)}.d-crit{background:var(--crit-m)}.d-unk{background:var(--unk-m)} /* cards */ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);margin:16px 0;overflow:hidden} .card-h{display:flex;align-items:center;gap:12px;padding:15px 18px;border-bottom:1px solid var(--line-soft)} .card-h .spacer,summary .spacer{flex:1} .chip{display:inline-flex;align-items:center;gap:5px;background:var(--hover);border:1px solid var(--line-soft);border-radius:999px;padding:2px 10px;font-size:11.5px;color:var(--ink-2)} .chip b{font-weight:650;color:var(--ink)} /* legend */ .legend{display:flex;gap:14px;font-size:11.5px;color:var(--ink-2)} .legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px} /* chart */ .chart-body{padding:10px 14px 6px;position:relative} svg.chart{width:100%;height:auto;display:block} .gridline{stroke:var(--grid);stroke-width:1} .axisline{stroke:var(--axis);stroke-width:1} .axis-t{fill:var(--muted);font-size:10.5px;font-family:inherit} .bar-t{fill:var(--ink-2);font-size:10.5px;font-weight:650;font-family:inherit} .seg-crit{fill:var(--crit-m)}.seg-warn{fill:var(--warn-m)}.seg-ok{fill:var(--ok-m)} .hit{fill:transparent;cursor:default} .hit:hover{fill:var(--hover)} .chart-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px} .tip{position:fixed;z-index:10;background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:9px 12px;font-size:12px;pointer-events:none;display:none;min-width:150px} .tip .t{font-weight:650;margin-bottom:6px;color:var(--ink-2);font-size:11.5px} .tip .r{display:flex;align-items:center;gap:7px;margin-top:3px} .tip .k{width:12px;height:3px;border-radius:2px;flex:none} .tip .v{font-weight:650;margin-left:auto;font-variant-numeric:tabular-nums} /* server sections */ summary{display:flex;align-items:center;gap:10px;padding:14px 18px;cursor:pointer;list-style:none;flex-wrap:wrap} summary::-webkit-details-marker{display:none} summary::before{content:'';border:solid var(--muted);border-width:0 1.6px 1.6px 0;padding:2.6px;transform:rotate(45deg);transition:transform .15s;margin-right:2px} details:not([open]) summary::before{transform:rotate(-45deg)} .srv{font-weight:650;font-size:14.5px} .table-tools{padding:4px 18px 10px} .search{display:inline-flex;align-items:center;gap:7px;background:var(--page);border:1px solid var(--line);border-radius:9px;padding:6px 11px;width:270px;color:var(--muted)} .search svg{width:13px;height:13px;flex:none} .search input{border:0;background:none;outline:0;font:inherit;color:var(--ink);width:100%} .search:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(42,120,214,.15)} /* tables */ .twrap{overflow-x:auto} table{border-collapse:collapse;width:100%;font-size:12.5px} th,td{padding:8px 12px;text-align:left;border-bottom:1px solid var(--line-soft);vertical-align:middle} th{color:var(--muted);font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;font-weight:650;cursor:pointer;user-select:none;white-space:nowrap;background:var(--card);position:sticky;top:0} th:hover{color:var(--ink-2)} th[data-asc='1']::after{content:' \25B4'} th[data-asc='0']::after{content:' \25BE'} tbody tr:hover{background:var(--hover)} tbody tr:last-child td{border-bottom:none} td:first-child,th:first-child{padding-left:18px} td:last-child,th:last-child{padding-right:18px} .mono{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;color:var(--ink-2)} .subj{max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .cdp{font-size:11px} .reach-ok{color:var(--ok);font-weight:600;font-size:11.5px} .reach-fail{color:var(--crit);font-weight:650;font-size:11.5px} /* days + meter */ .days{display:flex;align-items:center;gap:9px} .days>span:first-child{min-width:44px;font-weight:650} .meter{display:inline-block;width:52px;height:5px;border-radius:3px;overflow:hidden;flex:none} .meter i{display:block;height:100%;border-radius:3px} .m-ok{background:var(--ok-t)}.m-ok i{background:var(--ok-m)} .m-warn{background:var(--warn-t)}.m-warn i{background:var(--warn-m)} .m-crit{background:var(--crit-t)}.m-crit i{background:var(--crit-m)} .m-unk{background:var(--unk-t)}.m-unk i{background:var(--unk-m)} /* trend */ .trend-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:0 24px;padding:14px 18px 16px} .trend-h{font-size:12px;font-weight:650;color:var(--ink-2);display:flex;align-items:center;gap:8px;margin-bottom:8px} .trend-list{list-style:none;margin:0;padding:0;font-size:12.5px} .trend-list li{display:flex;align-items:center;padding:5px 0;border-bottom:1px solid var(--line-soft)} .trend-list li:last-child{border-bottom:none} .trend-host{font-weight:650;margin-right:6px} .empty-s{color:var(--muted);font-size:12.5px;padding:6px 0} /* empty states */ .empty{display:flex;align-items:center;justify-content:center;gap:9px;color:var(--muted);padding:26px 0;font-size:13px} .empty svg{width:17px;height:17px;opacity:.7} /* footer */ .foot{margin-top:28px;color:var(--muted);font-size:11.5px;display:flex;gap:8px;align-items:center} @media print{ body{background:#fff;padding:0} .card,.tile{box-shadow:none;break-inside:avoid} .search,.hit{display:none} details:not([open])>*:not(summary){display:block} } </style> '@ # -------------------------------------------------------------- script --- $script = @' <script> (function(){ var tip=document.createElement('div');tip.className='tip';document.body.appendChild(tip); function row(label,color,val){ var r=document.createElement('div');r.className='r'; var k=document.createElement('i');k.className='k';k.style.background=color; var l=document.createElement('span');l.textContent=label; var v=document.createElement('span');v.className='v';v.textContent=val; r.appendChild(k);r.appendChild(l);r.appendChild(v);return r; } var css=getComputedStyle(document.documentElement); document.querySelectorAll('.hit').forEach(function(h){ h.addEventListener('pointermove',function(e){ tip.textContent=''; var t=document.createElement('div');t.className='t';t.textContent=h.dataset.label.replace('–','–');tip.appendChild(t); var total=0; [['CRITICAL','--crit-m','crit'],['WARNING','--warn-m','warn'],['OK','--ok-m','ok']].forEach(function(s){ var n=parseInt(h.dataset[s[2]],10)||0;total+=n; if(n>0)tip.appendChild(row(s[0],css.getPropertyValue(s[1]).trim(),n)); }); if(total===0){var z=document.createElement('div');z.textContent='No expiries this week';z.style.color=css.getPropertyValue('--muted');tip.appendChild(z);} tip.style.display='block'; var x=e.clientX+14,y=e.clientY+14,r=tip.getBoundingClientRect(); if(x+r.width>innerWidth-8)x=e.clientX-r.width-12; if(y+r.height>innerHeight-8)y=e.clientY-r.height-12; tip.style.left=x+'px';tip.style.top=y+'px'; }); h.addEventListener('pointerleave',function(){tip.style.display='none';}); }); })(); function cwFilter(inp){ var q=inp.value.toLowerCase(), card=inp.closest('.card')||document, tb=card.querySelector('table tbody'); if(!tb)return; 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.removeAttribute('data-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||x.dataset.v!==undefined){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> '@ # ---------------------------------------------------------------- html --- $nextTileValue = if ($next) { "$([math]::Round($next.DaysRemaining, 1))<span style='font-size:15px;font-weight:600;color:var(--muted)'>days</span>" } else { '–' } $nextTileSub = if ($next) { "$(Encode $next.Role) on $(Encode $next.Host)" } else { 'no certificates in scope' } $critAccent = if ($critCount -gt 0) { ' accent-crit' } else { '' } $warnAccent = if ($warnCount -gt 0) { ' accent-warn' } else { '' } $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> <div class="wrap"> <header class="hdr"> <div class="mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 3.2V11c0 5-3.4 8.6-8 11-4.6-2.4-8-6-8-11V5.2L12 2z"/><path d="M8.5 12l2.5 2.5 4.5-4.5"/></svg></div> <div> <h1>$(Encode $Title)</h1> <div class="hdr-sub"><b>$($Snapshot.Count)</b> server$(if ($Snapshot.Count -ne 1) { 's' })<span class="sep">·</span><b>$($allCerts.Count)</b> certificates<span class="sep">·</span>generated $($now.ToString('yyyy-MM-dd HH:mm'))<span class="sep">·</span>cert-watchtower</div> </div> </header> <div class="tiles"> <div class="tile"><div class="l">Overall</div><div class="v">$((Badge $score) -replace "class='badge ", "class='badge lg ")</div><div class="s">worst finding across the fleet</div></div> <div class="tile$critAccent"><div class="l">Critical</div><div class="v tnum">$critCount$(if ($critCount -gt 0) { Dot 'CRITICAL' })</div><div class="s">certificates</div></div> <div class="tile$warnAccent"><div class="l">Warning</div><div class="v tnum">$warnCount$(if ($warnCount -gt 0) { Dot 'WARNING' })</div><div class="s">certificates</div></div> <div class="tile"><div class="l">Next expiry</div><div class="v tnum">$nextTileValue</div><div class="s">$nextTileSub</div></div> <div class="tile"><div class="l">CRL health</div><div class="v">$(Badge $crlLight[0])</div><div class="s">$(Encode $crlLight[1])</div></div> <div class="tile"><div class="l">CA health</div><div class="v">$(Badge $caLight[0])</div><div class="s">$(Encode $caLight[1])</div></div> </div> <section class="card"> <div class="card-h"><h2>Expiry timeline</h2><span class="chip">next 90 days</span><span class="spacer"></span> <div class="legend"><span><i style="background:var(--crit-m)"></i>Critical</span><span><i style="background:var(--warn-m)"></i>Warning</span><span><i style="background:var(--ok-m)"></i>OK</span></div> </div> <div class="chart-body"> <svg class="chart" viewBox="0 0 $svgW $svgH" role="img" aria-label="Certificate expiries per week, next 90 days">$($svg.ToString())</svg> $chartEmpty </div> </section> $trendHtml $($tables.ToString()) <section class="card"> <div class="card-h"><h2>CRL health</h2>$(if ($allCrls.Count) { "<span class='chip tnum'>$($allCrls.Count)</span>" })</div> <div class="twrap"><table><thead><tr><th>Severity</th><th>Host</th><th>Type</th><th>Issuer</th><th>Next update</th><th>Hours left</th><th>Distribution point</th><th>Signature</th></tr></thead> <tbody>$(if ($crlRows.Length) { $crlRows.ToString() } else { $emptyRow -f 'No CRLs referenced by the scanned certificates' })</tbody></table></div> </section> <section class="card"> <div class="card-h"><h2>ADCS CA health</h2>$(if ($allCas.Count) { "<span class='chip tnum'>$($allCas.Count)</span>" })</div> <div class="twrap"><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 ($caRows.Length) { $caRows.ToString() } else { $emptyRow -f 'No certificate authority in scope' })</tbody></table></div> </section> <div class="foot">cert-watchtower · agentless PKI monitoring · github.com/xGreeny/cert-watchtower</div> </div> $script </body> </html> "@ $html } |