Private/ConvertTo-InfraPulseHtmlReport.ps1
|
function ConvertTo-InfraPulseHtmlReport { [CmdletBinding()] param( [Parameter(Mandatory)] [object[]]$Reports, [Parameter(Mandatory)] [string]$Title ) $reportArray = @($Reports) if ($reportArray.Count -eq 0) { throw 'At least one report is required to build an HTML document.' } $builder = New-Object System.Text.StringBuilder $allResults = @($reportArray | ForEach-Object { @($_.Results) }) $aggregate = Get-InfraPulseSummary -Results $allResults $aggregateClass = Get-InfraPulseStatusCssClass -Status $aggregate.OverallStatus $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; } html { scroll-behavior: smooth; } 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; } button, input, select { font: inherit; } main { width: min(1480px, calc(100% - 32px)); margin: 32px auto 64px; } .hero { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: center; 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.8rem); line-height: 1; letter-spacing: -.04em; } .subtitle { margin: 12px 0 0; color: var(--muted); max-width: 760px; } .pulse-mark { width: 220px; max-width: 25vw; } .overall { display: inline-flex; align-items: center; gap: 8px; margin-top: 18px; padding: 8px 12px; border-radius: 999px; font-weight: 700; border: 1px solid currentColor; } .overall::before, .status::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(120px, 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: .78rem; letter-spacing: .08em; text-transform: uppercase; } .toolbar { position: sticky; top: 0; z-index: 10; display: grid; grid-template-columns: minmax(220px, 1fr) auto auto auto auto; gap: 10px; align-items: center; margin: 18px 0 32px; padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: rgba(16, 24, 39, .94); box-shadow: 0 10px 35px rgba(0, 0, 0, .24); backdrop-filter: blur(12px); } .control { width: 100%; min-height: 40px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 9px; background: #0b1220; color: var(--text); outline: none; } .control:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(99, 230, 190, .12); } .button { min-height: 40px; padding: 9px 14px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel-2); color: var(--text); cursor: pointer; } .button:hover { border-color: var(--accent); } .visible-count { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .8rem; white-space: nowrap; } .host { margin: 28px 0; border: 1px solid var(--line); border-radius: 18px; overflow: hidden; background: var(--panel); box-shadow: var(--shadow); } .host[hidden], tr[hidden] { display: none; } .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; } .tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } .tag { padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--accent-2); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .72rem; } .inventory { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 1px; background: var(--line); border-bottom: 1px solid var(--line); } .inventory div { padding: 14px 18px; background: var(--panel); } .inventory dt { margin: 0 0 4px; color: var(--muted); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; } .inventory dd { margin: 0; font-size: .92rem; overflow-wrap: anywhere; } .table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; min-width: 1140px; } 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; } tr:hover td { background: rgba(255,255,255,.018); } .status { 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); } .mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .82rem; } .muted { color: var(--muted); } .threshold { display: block; white-space: nowrap; } .recommendation { max-width: 380px; } .fleet table { min-width: 720px; } .fleet a { color: var(--accent-2); text-decoration: none; } .fleet a:hover { text-decoration: underline; } details { margin-top: 8px; } summary { color: var(--accent); cursor: pointer; font-size: .78rem; } pre { white-space: pre-wrap; word-break: break-word; max-width: 640px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: #090e19; color: #c9d7e8; font-size: .74rem; } .empty-state { display: none; margin: 28px 0; padding: 28px; border: 1px dashed var(--line); border-radius: 16px; color: var(--muted); text-align: center; } footer { margin-top: 28px; color: var(--muted); text-align: center; font-size: .8rem; } @media (max-width: 960px) { .hero { grid-template-columns: 1fr; } .pulse-mark { display: none; } .inventory { grid-template-columns: repeat(2, 1fr); } .toolbar { grid-template-columns: 1fr 1fr; } } @media (max-width: 560px) { main { width: min(100% - 18px, 1480px); margin-top: 10px; } .hero { padding: 22px; } .inventory { grid-template-columns: 1fr; } .host-header { align-items: flex-start; flex-direction: column; } .toolbar { position: static; grid-template-columns: 1fr; } } @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; } .toolbar { display: none; } .host { break-inside: avoid; box-shadow: none; } details { display: none; } } '@ $javaScript = @' (function () { const queryInput = document.getElementById('filter-query'); const statusSelect = document.getElementById('filter-status'); const resetButton = document.getElementById('filter-reset'); const printButton = document.getElementById('print-report'); const visibleCount = document.getElementById('visible-count'); const emptyState = document.getElementById('empty-state'); const rows = Array.from(document.querySelectorAll('article.host tbody tr')); const hosts = Array.from(document.querySelectorAll('article.host')); function applyFilters() { const query = queryInput.value.trim().toLowerCase(); const status = statusSelect.value; let shownRows = 0; let shownHosts = 0; hosts.forEach(function (host) { const hostMatch = !query || host.dataset.search.includes(query); const hostRows = Array.from(host.querySelectorAll('tbody tr')); let hostShownRows = 0; hostRows.forEach(function (row) { const statusMatch = status === 'all' || row.dataset.status === status; const textMatch = !query || hostMatch || row.dataset.search.includes(query); const show = statusMatch && textMatch; row.hidden = !show; if (show) { hostShownRows += 1; shownRows += 1; } }); host.hidden = hostShownRows === 0; if (!host.hidden) { shownHosts += 1; } }); visibleCount.textContent = shownRows + ' of ' + rows.length + ' checks | ' + shownHosts + ' hosts'; emptyState.style.display = shownRows === 0 ? 'block' : 'none'; } queryInput.addEventListener('input', applyFilters); statusSelect.addEventListener('change', applyFilters); resetButton.addEventListener('click', function () { queryInput.value = ''; statusSelect.value = 'all'; applyFilters(); queryInput.focus(); }); printButton.addEventListener('click', function () { window.print(); }); applyFilters(); }()); '@ $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''; script-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>') $null = $builder.AppendLine('<div class="eyebrow">InfraPulse // infrastructure telemetry</div>') $null = $builder.AppendLine('<h1>' + (ConvertTo-InfraPulseHtmlEncoded -Value $Title) + '</h1>') $environmentNames = @( $reportArray | ForEach-Object { $environmentProperty = $_.PSObject.Properties['EnvironmentName'] if ($null -ne $environmentProperty) { [string]$environmentProperty.Value } else { '' } } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique ) $subtitle = if ($environmentNames.Count -gt 0) { 'Read-only health telemetry for Windows infrastructure — ' + (ConvertTo-InfraPulseHtmlEncoded -Value ($environmentNames -join ', ')) + '. Generated ' + $generatedAt + '.' } else { 'Read-only health telemetry for Windows infrastructure. Generated ' + $generatedAt + '.' } $null = $builder.AppendLine('<p class="subtitle">' + $subtitle + '</p>') $null = $builder.AppendLine('<div class="overall ' + $aggregateClass + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $aggregate.OverallStatus) + '</div>') $null = $builder.AppendLine('</div>') $null = $builder.AppendLine('<svg class="pulse-mark" viewBox="0 0 260 100" role="img" aria-label="InfraPulse heartbeat"><defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="#63e6be" stop-opacity=".15"/><stop offset=".5" stop-color="#63e6be"/><stop offset="1" stop-color="#63e6be" stop-opacity=".15"/></linearGradient></defs><path d="M5 56h42l14-28 22 58 24-72 24 54 17-28 14 16h93" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="129" cy="68" r="4" fill="#63e6be"/></svg>') $null = $builder.AppendLine('</section>') $metrics = [ordered]@{ Hosts = $reportArray.Count Checks = $aggregate.Counts.Total Healthy = $aggregate.Counts.Healthy Warning = $aggregate.Counts.Warning Critical = $aggregate.Counts.Critical Unknown = $aggregate.Counts.Unknown Skipped = $aggregate.Counts.Skipped } $null = $builder.AppendLine('<section class="summary-grid" aria-label="Report summary">') foreach ($metricName in $metrics.Keys) { $class = switch ($metricName) { 'Healthy' { ' healthy' } 'Warning' { ' warning' } 'Critical' { ' critical' } 'Unknown' { ' unknown' } 'Skipped' { ' skipped' } default { '' } } $null = $builder.AppendLine('<div class="metric' + $class + '"><span class="value">' + $metrics[$metricName] + '</span><span class="label">' + $metricName + '</span></div>') } $null = $builder.AppendLine('</section>') if ($reportArray.Count -ge 2) { $null = $builder.AppendLine('<section class="host fleet" aria-label="Fleet overview">') $null = $builder.AppendLine('<header class="host-header"><div><h2 class="host-title">Fleet overview</h2><div class="host-meta">' + $reportArray.Count + ' hosts</div></div><span class="status ' + $aggregateClass + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $aggregate.OverallStatus) + '</span></header>') $null = $builder.AppendLine('<div class="table-wrap"><table>') $null = $builder.AppendLine('<thead><tr><th>Host</th><th>Status</th><th>Critical</th><th>Warning</th><th>Unknown</th><th>Healthy</th><th>Skipped</th><th>Duration</th></tr></thead><tbody>') for ($fleetIndex = 0; $fleetIndex -lt $reportArray.Count; $fleetIndex++) { $fleetReport = $reportArray[$fleetIndex] $fleetClass = Get-InfraPulseStatusCssClass -Status ([string]$fleetReport.OverallStatus) $fleetDuration = [string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0:N0} ms', [double]$fleetReport.DurationMs) $null = $builder.AppendLine('<tr>') $null = $builder.AppendLine('<td><a class="mono" href="#host-' + $fleetIndex + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $fleetReport.ComputerName) + '</a></td>') $null = $builder.AppendLine('<td><span class="status ' + $fleetClass + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $fleetReport.OverallStatus) + '</span></td>') $null = $builder.AppendLine('<td class="mono">' + [int]$fleetReport.Summary.Critical + '</td>') $null = $builder.AppendLine('<td class="mono">' + [int]$fleetReport.Summary.Warning + '</td>') $null = $builder.AppendLine('<td class="mono">' + [int]$fleetReport.Summary.Unknown + '</td>') $null = $builder.AppendLine('<td class="mono">' + [int]$fleetReport.Summary.Healthy + '</td>') $null = $builder.AppendLine('<td class="mono">' + [int]$fleetReport.Summary.Skipped + '</td>') $null = $builder.AppendLine('<td class="mono">' + (ConvertTo-InfraPulseHtmlEncoded -Value $fleetDuration) + '</td>') $null = $builder.AppendLine('</tr>') } $null = $builder.AppendLine('</tbody></table></div></section>') } $null = $builder.AppendLine('<section class="toolbar" aria-label="Report filters">') $null = $builder.AppendLine('<input id="filter-query" class="control" type="search" placeholder="Filter host, check, target or message" aria-label="Filter report">') $null = $builder.AppendLine('<select id="filter-status" class="control" aria-label="Filter by status"><option value="all">All statuses</option><option value="critical">Critical</option><option value="warning">Warning</option><option value="unknown">Unknown</option><option value="healthy">Healthy</option><option value="skipped">Skipped</option></select>') $null = $builder.AppendLine('<button id="filter-reset" class="button" type="button">Reset</button>') $null = $builder.AppendLine('<button id="print-report" class="button" type="button">Print</button>') $null = $builder.AppendLine('<span id="visible-count" class="visible-count" aria-live="polite"></span>') $null = $builder.AppendLine('</section>') for ($hostIndex = 0; $hostIndex -lt $reportArray.Count; $hostIndex++) { $report = $reportArray[$hostIndex] $hostClass = Get-InfraPulseStatusCssClass -Status ([string]$report.OverallStatus) $generated = if ($report.GeneratedAtUtc) { ([datetime]$report.GeneratedAtUtc).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') + ' UTC' } else { 'n/a' } $duration = [string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0:N0} ms', [double]$report.DurationMs) $tags = @($report.Tags | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) $inventorySearch = if ($null -eq $report.Inventory) { '' } else { ConvertTo-Json -InputObject (ConvertTo-InfraPulseSerializableValue -Value $report.Inventory) -Depth 4 -Compress } $hostSearch = (@([string]$report.ComputerName, [string]$report.RequestedComputerName, ($tags -join ' '), $inventorySearch) -join ' ').ToLowerInvariant() $reportEnvironmentProperty = $report.PSObject.Properties['EnvironmentName'] $reportEnvironment = if ($null -ne $reportEnvironmentProperty) { [string]$reportEnvironmentProperty.Value } else { '' } $hostMetaParts = @() if (-not [string]::IsNullOrWhiteSpace($reportEnvironment)) { $hostMetaParts += (ConvertTo-InfraPulseHtmlEncoded -Value $reportEnvironment) } $hostMetaParts += 'generated ' + (ConvertTo-InfraPulseHtmlEncoded -Value $generated) $hostMetaParts += 'duration ' + (ConvertTo-InfraPulseHtmlEncoded -Value $duration) $null = $builder.AppendLine('<article class="host" id="host-' + $hostIndex + '" data-search="' + (ConvertTo-InfraPulseHtmlEncoded -Value $hostSearch) + '">') $null = $builder.AppendLine('<header class="host-header"><div><h2 class="host-title">' + (ConvertTo-InfraPulseHtmlEncoded -Value $report.ComputerName) + '</h2><div class="host-meta">' + ($hostMetaParts -join ' | ') + '</div>') if ($tags.Count -gt 0) { $null = $builder.AppendLine('<div class="tags">') foreach ($tag in $tags) { $null = $builder.AppendLine('<span class="tag">' + (ConvertTo-InfraPulseHtmlEncoded -Value $tag) + '</span>') } $null = $builder.AppendLine('</div>') } $null = $builder.AppendLine('</div><span class="status ' + $hostClass + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $report.OverallStatus) + '</span></header>') if ($null -ne $report.Inventory) { $inventoryItems = [ordered]@{ 'Operating system' = [string]$report.Inventory.OperatingSystem 'Version / build' = (([string]$report.Inventory.Version) + ' / ' + ([string]$report.Inventory.BuildNumber)).Trim(' ', '/') 'Architecture' = [string]$report.Inventory.Architecture 'FQDN' = [string]$report.Inventory.Fqdn 'Manufacturer' = [string]$report.Inventory.Manufacturer 'Model' = [string]$report.Inventory.Model 'Domain' = [string]$report.Inventory.Domain 'PowerShell' = (([string]$report.Inventory.PowerShellEdition) + ' ' + ([string]$report.Inventory.PowerShellVersion)).Trim() } $null = $builder.AppendLine('<dl class="inventory">') foreach ($itemName in $inventoryItems.Keys) { $value = if ([string]::IsNullOrWhiteSpace([string]$inventoryItems[$itemName])) { 'n/a' } else { [string]$inventoryItems[$itemName] } $null = $builder.AppendLine('<div><dt>' + (ConvertTo-InfraPulseHtmlEncoded -Value $itemName) + '</dt><dd>' + (ConvertTo-InfraPulseHtmlEncoded -Value $value) + '</dd></div>') } $null = $builder.AppendLine('</dl>') } $null = $builder.AppendLine('<div class="table-wrap"><table>') $null = $builder.AppendLine('<thead><tr><th>Status</th><th>Check</th><th>Target</th><th>Message</th><th>Observed</th><th>Thresholds</th><th>Recommendation / evidence</th></tr></thead><tbody>') foreach ($result in @($report.Results)) { $statusClass = Get-InfraPulseStatusCssClass -Status ([string]$result.Status) $observed = if ($null -eq $result.ObservedValue -or [string]::IsNullOrWhiteSpace([string]$result.ObservedValue)) { '-' } else { [string]$result.ObservedValue } $thresholdParts = @() if (-not [string]::IsNullOrWhiteSpace([string]$result.WarningThreshold)) { $thresholdParts += '<span class="threshold">W: ' + (ConvertTo-InfraPulseHtmlEncoded -Value $result.WarningThreshold) + '</span>' } if (-not [string]::IsNullOrWhiteSpace([string]$result.CriticalThreshold)) { $thresholdParts += '<span class="threshold">C: ' + (ConvertTo-InfraPulseHtmlEncoded -Value $result.CriticalThreshold) + '</span>' } $thresholds = if ($thresholdParts.Count -eq 0) { '-' } else { $thresholdParts -join '' } $recommendation = if ([string]::IsNullOrWhiteSpace([string]$result.Recommendation)) { '<span class="muted">No action required.</span>' } else { ConvertTo-InfraPulseHtmlEncoded -Value $result.Recommendation } $evidenceJson = ConvertTo-Json -InputObject (ConvertTo-InfraPulseSerializableValue -Value $result.Evidence) -Depth 8 $rowSearch = (@([string]$result.Status, [string]$result.CheckName, [string]$result.Category, [string]$result.Target, [string]$result.Message, [string]$result.ObservedValue, [string]$result.Recommendation, [string]$result.Error) -join ' ').ToLowerInvariant() $null = $builder.AppendLine('<tr data-status="' + (ConvertTo-InfraPulseHtmlEncoded -Value (([string]$result.Status).ToLowerInvariant())) + '" data-search="' + (ConvertTo-InfraPulseHtmlEncoded -Value $rowSearch) + '">') $null = $builder.AppendLine('<td><span class="status ' + $statusClass + '">' + (ConvertTo-InfraPulseHtmlEncoded -Value $result.Status) + '</span></td>') $null = $builder.AppendLine('<td><span class="mono">' + (ConvertTo-InfraPulseHtmlEncoded -Value $result.CheckName) + '</span><br><span class="muted">' + (ConvertTo-InfraPulseHtmlEncoded -Value $result.Category) + '</span></td>') $null = $builder.AppendLine('<td>' + (ConvertTo-InfraPulseHtmlEncoded -Value $result.Target) + '</td>') $null = $builder.AppendLine('<td>' + (ConvertTo-InfraPulseHtmlEncoded -Value $result.Message) + '</td>') $null = $builder.AppendLine('<td class="mono">' + (ConvertTo-InfraPulseHtmlEncoded -Value $observed) + '</td>') $null = $builder.AppendLine('<td class="mono">' + $thresholds + '</td>') $null = $builder.AppendLine('<td class="recommendation">' + $recommendation + '<details><summary>Evidence</summary><pre>' + (ConvertTo-InfraPulseHtmlEncoded -Value $evidenceJson) + '</pre></details></td>') $null = $builder.AppendLine('</tr>') } $null = $builder.AppendLine('</tbody></table></div></article>') } $null = $builder.AppendLine('<div id="empty-state" class="empty-state">No checks match the current filters.</div>') $versions = @($reportArray | ForEach-Object { [string]$_.ToolVersion } | Select-Object -Unique) -join ', ' $null = $builder.AppendLine('<footer>InfraPulse ' + (ConvertTo-InfraPulseHtmlEncoded -Value $versions) + ' | read-only infrastructure health report</footer>') $null = $builder.AppendLine('<script>' + $javaScript + '</script>') $null = $builder.AppendLine('</main></body></html>') return $builder.ToString() } |