Private/Reporting.ps1
|
# Copyright (c) 2026 Broadcom. All Rights Reserved. # Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc. # and/or its subsidiaries. # # ============================================================================= # # SOFTWARE LICENSE AGREEMENT # # Copyright (c) CA, Inc. All rights reserved. # # You are hereby granted a non-exclusive, worldwide, royalty-free license # under CA, Inc.'s copyrights to use, copy, modify, and distribute this # software in source code or binary form for use in connection with CA, Inc. # products. # # This copyright notice shall be included in all copies or substantial # portions of the software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. # # ============================================================================= #region Reporting function ConvertTo-VcfCheckReportJson { <# .SYNOPSIS Builds the aggregate run JSON object from a list of check results. .DESCRIPTION Produces the schema consumed by the bundled Python report server: a run-level summary (counts per status plus blockingFailures) and the full array of per-check result objects. .PARAMETER RunId Unique identifier for this run (e.g. a timestamp string). .PARAMETER SddcManagerFqdn The SDDC Manager this run was executed against. .PARAMETER CheckId The explicit check ID(s) that were requested for this run. Empty means every check in the catalog ran. .PARAMETER Results Array of VcfCheck.Result objects (from New-VcfCheckResult). .PARAMETER StartedAt UTC timestamp when the run began. .PARAMETER CompletedAt UTC timestamp when the run finished (or "now" for a partial/in-progress flush). .PARAMETER VcfVersion The running SDDC Manager's version string for this environment (Get-VcfCheckVcfVersion), e.g. "5.2.1.0-24305054". Empty when it could not be resolved - a non-fatal condition, see Invoke-VcfCheck. .OUTPUTS [PSCustomObject] ready for ConvertTo-Json. #> [CmdletBinding()] [OutputType([PSObject])] Param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$RunId, [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$SddcManagerFqdn = '', [Parameter(Mandatory = $false)] [String[]]$CheckId = @(), [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Results, [Parameter(Mandatory = $true)] [DateTime]$StartedAt, [Parameter(Mandatory = $true)] [DateTime]$CompletedAt, [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$VcfVersion = '' ) $summary = [Ordered]@{ total = $Results.Count pass = ($Results | Where-Object Status -eq 'Pass').Count warning = ($Results | Where-Object Status -eq 'Warning').Count fail = ($Results | Where-Object Status -eq 'Fail').Count error = ($Results | Where-Object Status -eq 'Error').Count skipped = ($Results | Where-Object Status -eq 'Skipped').Count blockingFailures = ($Results | Where-Object { $_.Status -eq 'Fail' -and $_.Blocking }).Count } return [PSCustomObject]@{ runId = $RunId toolVersion = $Script:VcfCheckVersion vcfVersion = $VcfVersion startedAt = $StartedAt.ToUniversalTime().ToString('o') completedAt = $CompletedAt.ToUniversalTime().ToString('o') sddcManagerFqdn = $SddcManagerFqdn checkIds = $CheckId summary = $summary results = $Results } } function Set-VcfCheckReportFileWithRetry { <# .SYNOPSIS Writes report content to a file, retrying on a transient Windows file-lock error. .DESCRIPTION The Python report server polls the same file (Tools/Start-VcfCheckServer.py's _load_latest_findings_json) while this engine is writing it, via Set-Content's non-atomic write. On Windows, a reader's open() call taken at the exact moment Set-Content holds its own exclusive write handle raises "The process cannot access the file because it is being used by another process" instead of the truncated-read race POSIX allows - confirmed live as an unhandled crash mid-run when this collided with the final (non-partial-flush) report write. A short retry-with-backoff treats that specific error as transient instead of letting it abort the entire run. .PARAMETER LiteralPath Path to the file to write. .PARAMETER Value Content to write. .OUTPUTS None. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [String]$LiteralPath, [Parameter(Mandatory = $true)] [String]$Value ) $maxAttempts = 5 for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { try { Set-Content -LiteralPath $LiteralPath -Value $Value -ErrorAction Stop return } catch [System.IO.IOException] { if ($attempt -eq $maxAttempts) { throw [System.InvalidOperationException]::new("Could not write report file `"$LiteralPath`" after $maxAttempts attempts: $($_.Exception.Message)") } Write-LogMessage -Type DEBUG -Message "Report file '$LiteralPath' was locked by another process (attempt $attempt/$maxAttempts); retrying." Start-Sleep -Milliseconds (200 * $attempt) } } } function Write-VcfCheckReport { <# .SYNOPSIS Writes a run report to disk as both <environmentName>-<runId>-findings.json and latest.json. .DESCRIPTION The bundled Python report server polls the output directory for these files - there is no network API between the PowerShell engine and the Python viewer. Called after every check completes (partial flush) so an interrupted run still yields a usable report, and once more at the end of the run with the final result set. The filename is keyed on the report's runId, so every flush for a given run overwrites the same file rather than accumulating a new file per check. .PARAMETER Report The object returned by ConvertTo-VcfCheckReportJson. .PARAMETER OutputPath Directory to write <environmentName>-<runId>-findings.json and latest.json into. Created if missing. .PARAMETER EnvironmentName Human-readable name of the environment (from environments.json), used in report filenames. When omitted, falls back to the run-<id>.json format for backwards compatibility. .OUTPUTS [String] path to the run-specific JSON file written. .EXAMPLE Write-VcfCheckReport -Report $report -OutputPath "$env:VcfCheckBaseDirectory\Findings" -EnvironmentName "VCF520-vRSLCM" #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [PSObject]$Report, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$OutputPath, [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$EnvironmentName = '' ) if (-not (Test-Path -LiteralPath $OutputPath -PathType Container)) { $null = New-Item -ItemType Directory -Path $OutputPath -Force } $resultFragments = foreach ($result in $Report.results) { $resultJson = $null foreach ($depth in @(12, 8, 5, 3, 2)) { try { $resultJson = $result | ConvertTo-Json -Depth $depth -ErrorAction Stop break } catch { if ($depth -eq 2) { throw $_ } Write-LogMessage -Type DEBUG -Message "Result '$($result.CheckId)' serialization at -Depth $depth failed ($($_.Exception.Message)); retrying at a shallower depth." } } $resultJson } $reportWithoutResults = $Report | Select-Object -Property * -ExcludeProperty results $topJson = $reportWithoutResults | ConvertTo-Json -Depth 5 $resultsArrayJson = '[' + ($resultFragments -join ',') + ']' $json = $topJson.TrimEnd().TrimEnd('}') + ',"results":' + $resultsArrayJson + '}' if ([String]::IsNullOrWhiteSpace($EnvironmentName)) { $runFilePath = Join-Path -Path $OutputPath -ChildPath "run-$($Report.runId).json" } else { $runFilePath = Join-Path -Path $OutputPath -ChildPath "$EnvironmentName-$($Report.runId)-findings.json" } $latestFilePath = Join-Path -Path $OutputPath -ChildPath 'latest.json' Set-VcfCheckReportFileWithRetry -LiteralPath $runFilePath -Value $json Set-VcfCheckReportFileWithRetry -LiteralPath $latestFilePath -Value $json return $runFilePath } function Get-VcfCheckTimestampedReportPath { <# .SYNOPSIS Builds a human-friendly, timestamp-based path for a completed run's report file. .DESCRIPTION Findings/report filenames are keyed on the run's internal run ID (e.g. a short hex string handed down by the bundled Python server) while the run is in progress, so every partial flush for that run overwrites the same file. Once the run is complete there is no further need for that consistency, and the run ID is not a useful lookup key for a human browsing the Findings folder - this swaps it for a "yyyyMMdd-HHmmss" timestamp. .PARAMETER Path Path to an existing report artifact (e.g. the findings JSON file) whose filename contains -RunId. .PARAMETER RunId The run ID currently embedded in the filename. .PARAMETER CompletedAt The run's completion time, used to build the replacement timestamp. .OUTPUTS [String] the new path, or the original -Path unchanged if -RunId was not found in the filename. .EXAMPLE Get-VcfCheckTimestampedReportPath -Path 'VCF52-VRSLCM-df248dbfd49b-findings.json' -RunId 'df248dbfd49b' -CompletedAt (Get-Date) #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Path, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$RunId, [Parameter(Mandatory = $true)] [DateTime]$CompletedAt ) $leaf = Split-Path -Path $Path -Leaf if ($leaf -notmatch [Regex]::Escape($RunId)) { return $Path } $directory = Split-Path -Path $Path -Parent $timestamp = $CompletedAt.ToString('yyyyMMdd-HHmmss') $newLeaf = $leaf -replace [Regex]::Escape($RunId), $timestamp if ([String]::IsNullOrWhiteSpace($directory)) { return $newLeaf } return Join-Path -Path $directory -ChildPath $newLeaf } function Write-VcfCheckCheckProgress { <# .SYNOPSIS Prints a "[N/Total] Running <check>..." line before a check starts. .DESCRIPTION This module's own replacement for PowerCLI cmdlets' (Invoke-VMScript in particular) default Write-Progress rendering, which surfaces as raw, unstyled "[percent complete: N]" noise in the console - confirmed live to be genuinely confusing/unhelpful output, not useful progress information, since it reflects one internal API call's wait state rather than the run's actual progress. $ProgressPreference is set to SilentlyContinue for the whole run (see Invoke-VcfCheck) to suppress that noise; this function is the intentional replacement, so a live run is never completely silent between checks. .PARAMETER Index 1-based position of this check in the run. .PARAMETER Total Total number of checks in this run. .PARAMETER CheckId The check ID. .PARAMETER DisplayName The check's human-readable title (Data/CheckCatalog.json's "displayName") - shown instead of -CheckId so a live run reads like the report a user will see, not the internal id. .OUTPUTS None. Writes directly to the console. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [Int]$Index, [Parameter(Mandatory = $true)] [Int]$Total, [Parameter(Mandatory = $true)] [String]$CheckId, [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$DisplayName = '' ) $label = if ($DisplayName) { $DisplayName } else { $CheckId } Write-Host "[$Index/$Total] Running $label..." -ForegroundColor Gray } function Write-VcfCheckCheckOutcome { <# .SYNOPSIS Prints the one-line, color-coded outcome of a single check immediately after it runs. .DESCRIPTION Paired with Write-VcfCheckCheckProgress - together they give real-time, per-check feedback for a live run, replacing the raw PowerCLI progress-bar noise this project deliberately suppresses. Uses the same color convention as Write-VcfCheckConsoleSummary (Red = blocking fail, Yellow = fail/warning, DarkYellow = error, Green = pass). .PARAMETER Result The VcfCheck.Result object just produced for this check. .OUTPUTS None. Writes directly to the console. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [PSObject]$Result ) $color = switch ($Result.Status) { 'Pass' { 'Green' } 'Skipped' { 'DarkGray' } 'Warning' { 'Yellow' } 'Fail' { if ($Result.Blocking) { 'Red' } else { 'Yellow' } } 'Error' { 'DarkYellow' } default { 'White' } } $suffix = if ($Result.Status -eq 'Fail' -and $Result.Blocking) { 'BLOCKING FAIL' } else { $Result.Status.ToUpperInvariant() } $baseLabel = if ($Result.DisplayName) { $Result.DisplayName } else { $Result.CheckId } $label = if ($Result.Component) { "$baseLabel [$($Result.Component)]" } elseif ($Result.Domain) { "$baseLabel [$($Result.Domain)]" } else { $baseLabel } $durationSuffix = if ($null -ne $Result.DurationMs) { " ($(Format-VcfCheckDuration -Milliseconds $Result.DurationMs))" } else { '' } Write-Host " -> ${label}: $suffix$durationSuffix" -ForegroundColor $color } function Write-VcfCheckSubProgress { <# .SYNOPSIS Reports a long-running check's internal iteration progress (e.g. "host 3/12") for the browser UI to display alongside the per-check progress bar. .DESCRIPTION Writes a small progress.json into $Context.OutputPath - the same directory latest.json lives in - polled by the bundled Python server's /api/run/status endpoint and rendered by Tools/vcf-check-ui.html as a sub-progress line under "Current: <check name>". A check with only one item (or that never calls this) simply never gets a sub-progress line. Best-effort: a write failure here must never fail the check itself, so all errors are swallowed after a DEBUG log line. .PARAMETER Context The VcfCheck.Context object. No-ops if Context.OutputPath was never set (e.g. a check function invoked directly in a unit test, outside Invoke-VcfCheck). .PARAMETER Current 1-based index of the item currently being processed. .PARAMETER Total Total number of items being iterated. .PARAMETER Label Human-readable name of the current item (e.g. an ESX host's name). .PARAMETER Unit What Current/Total are counting, rendered by the browser UI (e.g. "hosts", "poll attempts"). Defaults to 'hosts' - every caller before this parameter existed was a per-host loop, and the UI's rendering hardcoded "Scanning N/Total hosts" accordingly; this default preserves that exact wording for those callers unchanged. .OUTPUTS None. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [PSObject]$Context, [Parameter(Mandatory = $true)] [Int]$Current, [Parameter(Mandatory = $true)] [Int]$Total, [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Label = '', [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [String]$Unit = 'hosts' ) if ([String]::IsNullOrWhiteSpace($Context.OutputPath)) { return } try { if (-not (Test-Path -LiteralPath $Context.OutputPath -PathType Container)) { $null = New-Item -ItemType Directory -Path $Context.OutputPath -Force } $payload = [Ordered]@{ current = $Current total = $Total label = $Label unit = $Unit updatedAt = [DateTime]::UtcNow.ToString('o') } $progressPath = Join-Path -Path $Context.OutputPath -ChildPath 'progress.json' Set-Content -LiteralPath $progressPath -Value ($payload | ConvertTo-Json -Depth 2) -ErrorAction Stop } catch { Write-LogMessage -Type DEBUG -Message "Could not write sub-progress file: $($_.Exception.Message)" } } function Clear-VcfCheckSubProgress { <# .SYNOPSIS Clears any sub-progress reported by the check that just finished, so a later, shorter check never displays a stale "host 12/12" line left over from a prior check. .DESCRIPTION Called by Invoke-VcfCheck immediately after every check returns, regardless of that check's status - not just the ones that call Write-VcfCheckSubProgress. Best-effort, same as Write-VcfCheckSubProgress. .PARAMETER Context The VcfCheck.Context object. No-ops if Context.OutputPath was never set. .OUTPUTS None. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [PSObject]$Context ) if ([String]::IsNullOrWhiteSpace($Context.OutputPath)) { return } $progressPath = Join-Path -Path $Context.OutputPath -ChildPath 'progress.json' try { if (Test-Path -LiteralPath $progressPath -PathType Leaf) { Remove-Item -LiteralPath $progressPath -Force -ErrorAction Stop } } catch { Write-LogMessage -Type DEBUG -Message "Could not clear sub-progress file: $($_.Exception.Message)" } } function Format-VcfCheckDuration { <# .SYNOPSIS Formats a duration in milliseconds as a short, human-readable string. .PARAMETER Milliseconds Duration in milliseconds (e.g. VcfCheck.Result's DurationMs). .OUTPUTS [String] "123ms" for sub-second durations, otherwise "12.3s". .EXAMPLE Format-VcfCheckDuration -Milliseconds 46150.85 # '46.2s' #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [Double]$Milliseconds ) if ($Milliseconds -lt 1000) { return "$([Math]::Round($Milliseconds))ms" } return "$([Math]::Round($Milliseconds / 1000, 1))s" } function Write-VcfCheckConsoleSummary { <# .SYNOPSIS Prints a human-readable pass/fail summary of a precheck run to the console. .DESCRIPTION Complements the JSON report (machine-readable, for the bundled web viewer) with a plain-text summary a user can read directly in their terminal without opening a browser: blocking failures first, then non-blocking failures, warnings, errors, then a one-line Pass/Skipped count. Every line includes the check's Detail/Exception text (already passed through Protect-VcfCheckLogMessage at result-creation time in New-VcfCheckResult), so "what failed and why" is visible without cross-referencing the JSON. .PARAMETER Results Array of VcfCheck.Result objects. .OUTPUTS None. Writes to the console. .EXAMPLE Write-VcfCheckConsoleSummary -Results $results #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Results ) $blockingFailures = @($Results | Where-Object { $_.Status -eq 'Fail' -and $_.Blocking }) $nonBlockingFailures = @($Results | Where-Object { $_.Status -eq 'Fail' -and -not $_.Blocking }) $warnings = @($Results | Where-Object { $_.Status -eq 'Warning' }) $errors = @($Results | Where-Object { $_.Status -eq 'Error' }) $passed = @($Results | Where-Object { $_.Status -eq 'Pass' }) $skipped = @($Results | Where-Object { $_.Status -eq 'Skipped' }) Write-Host '' Write-Host '===== VcfCheck summary =====' -ForegroundColor Cyan # Detail (and Remediation) can be multi-line (e.g. sddc_bom_check's per-component breakdown) - # a bare Write-Host " $Text" only indents the first line, leaving every subsequent # line unindented and visually detached from the check it belongs to (confirmed live, # 2026-07-16). Every line gets the same indent/color instead. $writeIndentedLines = { Param ([String]$Text, [String]$Color) foreach ($line in ($Text -split "`r?`n")) { Write-Host " $line" -ForegroundColor $Color } } if ($blockingFailures.Count -gt 0) { Write-Host '' Write-Host "BLOCKING FAILURES ($($blockingFailures.Count)) - will interrupt the upgrade:" -ForegroundColor Red foreach ($result in $blockingFailures) { Write-Host " [FAIL] $($result.CheckId) - $($result.DisplayName)" -ForegroundColor Red & $writeIndentedLines $result.Detail 'Red' if ($result.Remediation) { & $writeIndentedLines "Remediation: $($result.Remediation)" 'Red' } } } if ($nonBlockingFailures.Count -gt 0) { Write-Host '' Write-Host "FAILURES ($($nonBlockingFailures.Count)):" -ForegroundColor Yellow foreach ($result in $nonBlockingFailures) { Write-Host " [FAIL] $($result.CheckId) - $($result.DisplayName)" -ForegroundColor Yellow & $writeIndentedLines $result.Detail 'Yellow' if ($result.Remediation) { & $writeIndentedLines "Remediation: $($result.Remediation)" 'Yellow' } } } if ($warnings.Count -gt 0) { Write-Host '' Write-Host "WARNINGS ($($warnings.Count)):" -ForegroundColor Yellow foreach ($result in $warnings) { Write-Host " [WARN] $($result.CheckId) - $($result.DisplayName)" -ForegroundColor Yellow & $writeIndentedLines $result.Detail 'Yellow' if ($result.Remediation) { & $writeIndentedLines "Remediation: $($result.Remediation)" 'Yellow' } } } if ($errors.Count -gt 0) { Write-Host '' Write-Host "TOOL ERRORS ($($errors.Count)) - the check itself could not complete:" -ForegroundColor DarkYellow foreach ($result in $errors) { $errorText = if (-not [String]::IsNullOrWhiteSpace($result.Exception)) { $result.Exception } else { $result.Detail } Write-Host " [ERROR] $($result.CheckId) - $($result.DisplayName): $errorText" -ForegroundColor DarkYellow } } if ($skipped.Count -gt 0) { Write-Host '' Write-Host "SKIPPED ($($skipped.Count)) - not run, with reason:" -ForegroundColor DarkGray foreach ($result in $skipped) { Write-Host " [SKIP] $($result.CheckId) - $($result.DisplayName)" -ForegroundColor DarkGray & $writeIndentedLines $result.Detail 'DarkGray' } } Write-Host '' Write-Host "Pass: $($passed.Count) Skipped: $($skipped.Count) Warning: $($warnings.Count) Fail: $($nonBlockingFailures.Count) Blocking Fail: $($blockingFailures.Count) Error: $($errors.Count) Total: $($Results.Count)" -ForegroundColor Cyan Write-Host '================================' -ForegroundColor Cyan Write-Host '' } function Export-VcfCheckReportCsv { <# .SYNOPSIS Exports a precheck run's results to a flat CSV file. .DESCRIPTION A portable, spreadsheet-friendly alternative to the JSON report - useful for sharing a run's results with a stakeholder who doesn't want to open the bundled web viewer (a plain tabular export alongside the primary report, not a replacement for it). Deliberately does not include Rows: each check defines its own row schema, so a run mixing several checks' Rows has no single consistent set of CSV columns to flatten them into. Rows is JSON/HTML-report-only - see ConvertTo-VcfCheckReportJson and Export-VcfCheckReportHtml. .PARAMETER Results Array of VcfCheck.Result objects. .PARAMETER Path Destination .csv file path. .OUTPUTS [String] the path written. .EXAMPLE Export-VcfCheckReportCsv -Results $results -Path "$env:VcfCheckBaseDirectory\Findings\run-20260716.csv" #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Results, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Path ) $parentDirectory = Split-Path -Parent $Path if ($parentDirectory -and -not (Test-Path -LiteralPath $parentDirectory -PathType Container)) { $null = New-Item -ItemType Directory -Path $parentDirectory -Force } $Results | Select-Object CheckId, Area, Domain, DomainType, Component, DisplayName, Status, Blocking, TargetComponent, Detail, Remediation, StartedAt, CompletedAt, DurationMs, Exception | Export-Csv -LiteralPath $Path -NoTypeInformation -ErrorAction Stop return $Path } function ConvertTo-VcfCheckHostHierarchyJson { <# .SYNOPSIS Converts hosts grouped by vCenter and cluster into a hierarchical JSON structure. .DESCRIPTION Takes a hashtable of vCenter FQDN → hosts and produces a JSON string where vCenter FQDN is the top-level key for readability: { "vcenter-fqdn": { "clusters": [ { "clusterName": "...", "esxHostNames": [...] }, ... ] }, ... } This improves readability in the UI and provides clean output for eventual JSON export. Handles both: - VMHost objects (uses .Parent.Name for cluster, .Name for host) - AdvancedSetting objects (uses .Entity.Parent.Name for cluster, .Entity.Name for host) .PARAMETER HostsByVcenter Hashtable where keys are vCenter FQDNs and values are collections of host objects (VMHost or AdvancedSetting). .OUTPUTS [String] Formatted JSON string with vCenter FQDN as top-level key, then Cluster → Hosts hierarchy. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [Hashtable]$HostsByVcenter ) if ($HostsByVcenter.Count -eq 0) { return '{}' } $hierarchy = [Ordered]@{} foreach ($vcenterFqdn in ($HostsByVcenter.Keys | Sort-Object)) { $hosts = $HostsByVcenter[$vcenterFqdn] # Group hosts by cluster $hostsByCluster = @{} foreach ($hostItem in $hosts) { # Detect whether this is an AdvancedSetting (has Entity) or direct VMHost $vmHost = if ($hostItem.Entity) { $hostItem.Entity } else { $hostItem } $clusterName = $vmHost.Parent.Name $hostName = $vmHost.Name if (-not $hostsByCluster.ContainsKey($clusterName)) { $hostsByCluster[$clusterName] = @() } $hostsByCluster[$clusterName] += $hostName } # Build clusters array for this vCenter $clustersArray = @() foreach ($cluster in ($hostsByCluster.Keys | Sort-Object)) { $sortedHosts = @($hostsByCluster[$cluster] | Sort-Object) $clustersArray += @{ clusterName = $cluster esxHostNames = $sortedHosts } } $hierarchy[$vcenterFqdn] = @{ clusters = $clustersArray } } # See Write-VcfCheckReport for why this is a single depth rather than a try/catch retry # loop - ConvertTo-Json does not throw on exceeding -Depth, it silently stringifies whatever # it finds past the limit, so a catch-based retry never actually recovers anything. This # structure only nests 3 levels deep (vCenter -> clusters -> clusterName/esxHostNames), so # -Depth 5 already has headroom. return $hierarchy | ConvertTo-Json -Depth 5 } function ConvertTo-VcfCheckHtmlEncoded { <# .SYNOPSIS HTML-encodes a value for safe interpolation into Export-VcfCheckReportHtml's output. .DESCRIPTION The single shared escaping helper for the static HTML report - every dynamic value (Detail, Remediation, TargetComponent, a Rows cell, a derived table header, etc.) must pass through this before being interpolated into the document, since that data ultimately originates from live SDDC Manager/vCenter/NSX API responses and appliance command output, not trusted input. Wraps [System.Net.WebUtility]::HtmlEncode (available cross-platform on PowerShell 7) rather than System.Web.HttpUtility, which is not reliably available there. .PARAMETER Value The value to encode. Coerced to a string first if not already one; $null becomes an empty string. .OUTPUTS [String] the HTML-encoded value. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $false)] [AllowNull()] [Object]$Value = $null ) if ($null -eq $Value) { return '' } return [System.Net.WebUtility]::HtmlEncode([String]$Value) } function ConvertTo-VcfCheckHtmlRemediationEncoded { <# .SYNOPSIS HTML-encodes a Remediation string, rendering any `[text](url)` markdown links it contains as clickable anchors. .DESCRIPTION CheckCatalog.json's remediation field sometimes links a Broadcom KB or techdocs page using markdown link syntax (e.g. "[KB 439473](https://knowledge.broadcom.com/...)"). The rest of the report has no markdown rendering, so without this the link shows up as literal bracket syntax instead of a clickable link. Runs ConvertTo-VcfCheckHtmlEncoded first so the whole string is safe to interpolate, then promotes the (now HTML-encoded) markdown link syntax to an <a> tag - the encoded text and URL are already entity-escaped at that point, so wrapping them in a tag afterwards introduces no unescaped content. .PARAMETER Value The remediation text to encode. Coerced to a string first if not already one; $null becomes an empty string. .OUTPUTS [String] the HTML-encoded value with any markdown links rendered as <a> tags. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $false)] [AllowNull()] [Object]$Value = $null ) $encoded = ConvertTo-VcfCheckHtmlEncoded -Value $Value return [Regex]::Replace($encoded, '\[([^\]]+)\]\((https?://[^\s)]+)\)', '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>') } function Get-VcfCheckHtmlStatusClass { <# .SYNOPSIS Maps a result's Status/Blocking to the CSS status class shared by the report's nav, summary table, and detail cards. .DESCRIPTION Mirrors Write-VcfCheckCheckOutcome's console color convention exactly: a non-blocking Fail renders the same as a Warning, and only a blocking Fail gets its own treatment - kept identical here so the console, live viewer, and static report never disagree about how severe a given result looks. .PARAMETER Status The result's Status value. .PARAMETER Blocking The result's Blocking value. .OUTPUTS [String] one of pass|warning|fail|error|skipped. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [String]$Status, [Parameter(Mandatory = $false)] [Bool]$Blocking = $false ) switch ($Status) { 'Pass' { return 'pass' } 'Skipped' { return 'skipped' } 'Warning' { return 'warning' } 'Fail' { if ($Blocking) { return 'fail' } else { return 'warning' } } 'Error' { return 'error' } default { return 'skipped' } } } function Get-VcfCheckReportStylesheet { <# .SYNOPSIS Extracts the browser report's inline <style> block from Tools/vcf-check-ui.html so the static HTML report can embed the exact same CSS. .DESCRIPTION The single source of truth for this report's visual design is Tools/vcf-check-ui.html - both surfaces must always look identical, so this reads that file's own <style>...</style> text verbatim at generation time rather than maintaining a second, hand-written copy that can drift out of sync. Fails loudly (throws) when the file is missing or has no <style> block - there is no stale hand-written fallback to silently degrade to. .PARAMETER Path Path to vcf-check-ui.html. Defaults to Tools/vcf-check-ui.html relative to this module. .OUTPUTS [String] the CSS text between the <style> and </style> tags. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [String]$Path = (Join-Path -Path $PSScriptRoot -ChildPath (Join-Path -Path '..' -ChildPath (Join-Path -Path 'Tools' -ChildPath 'vcf-check-ui.html'))) ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw [System.InvalidOperationException]::new("Cannot extract report stylesheet: `"$Path`" was not found.") } try { $html = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop } catch { throw [System.InvalidOperationException]::new("Cannot extract report stylesheet: failed to read `"$Path`": $($_.Exception.Message)") } $styleMatch = [Regex]::Match($html, '<style>(.*?)</style>', [Text.RegularExpressions.RegexOptions]::Singleline) if (-not $styleMatch.Success) { throw [System.InvalidOperationException]::new("Cannot extract report stylesheet: no <style> block found in `"$Path`".") } return $styleMatch.Groups[1].Value } function Format-VcfCheckHtmlDocumentHead { <# .SYNOPSIS Builds the <head> element (title + inline styles) for the static HTML report. .DESCRIPTION A single inline <style> block, no external assets - this document must open standalone with no server. The CSS itself is extracted verbatim from Tools/vcf-check-ui.html by Get-VcfCheckReportStylesheet, not hand-written here, so the static report and the browser report's downloadable export always share one canonical stylesheet. Navigation is pure CSS (anchor links + native <details>/<summary> disclosure) - deliberately no <script> at all, so this document can never regress into the stored-XSS pattern. .PARAMETER Title Document <title> text. Must already be HTML-encoded by the caller. .OUTPUTS [String] the <head>...</head> markup. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [String]$Title ) $stylesheet = Get-VcfCheckReportStylesheet return @" <head> <meta charset="utf-8"> <title>$Title</title> <style> $stylesheet </style> </head> "@ } function Format-VcfCheckHtmlSummaryTile { <# .SYNOPSIS Builds the static status/component count tiles for the HTML report. .DESCRIPTION Mirrors Tools/vcf-check-ui.html's buildExportTilesHtml() markup and classes (.tiles/.tile/.tile-<key>/.count/.label) exactly, but omits the .filter-check icon and the data-filter-key/data-area-filter-key attributes it uses to drive click-to-filter - this document has no script to respond to a click, so the tiles are purely visual here. .PARAMETER Summary The report's summary object (ConvertTo-VcfCheckReportJson), with blockingFailures/fail/ warning/error/skipped/pass counts. .PARAMETER Results Array of VcfCheck.Result objects, used to compute the per-area tile counts. .OUTPUTS [String] the tiles markup. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [PSObject]$Summary, [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Results ) $statusTiles = [Ordered]@{ blockingFailures = 'Blocking Failures' fail = 'Fail' warning = 'Warning' error = 'Error' skipped = 'Skipped' pass = 'Pass' } $sb = [System.Text.StringBuilder]::new() $null = $sb.Append('<div class="tiles-section"><p class="tiles-section-label">By status</p><div class="tiles">') foreach ($key in $statusTiles.Keys) { $count = [int]$Summary.$key $blockingClass = if ($key -eq 'blockingFailures') { ' blocking' } else { '' } $null = $sb.Append('<div class="tile tile-').Append($key).Append($blockingClass).Append('"><div class="count">').Append($count) $null = $sb.Append('</div><div class="label">').Append((ConvertTo-VcfCheckHtmlEncoded -Value $statusTiles[$key])).Append('</div></div>') } $null = $sb.Append('</div></div>') $areaOrder = [System.Collections.Generic.List[String]]::new() $areaCounts = @{} foreach ($result in $Results) { $area = if ($result.Area) { $result.Area } else { 'Other' } if (-not $areaCounts.ContainsKey($area)) { $areaCounts[$area] = 0 $areaOrder.Add($area) } $areaCounts[$area]++ } $null = $sb.Append('<div class="tiles-section"><p class="tiles-section-label">By component</p><div class="tiles">') foreach ($area in $areaOrder) { $null = $sb.Append('<div class="tile"><div class="count">').Append($areaCounts[$area]) $null = $sb.Append('</div><div class="label">').Append((ConvertTo-VcfCheckHtmlEncoded -Value $area)).Append('</div></div>') } $null = $sb.Append('</div></div>') return $sb.ToString() } function Format-VcfCheckHtmlRunSummary { <# .SYNOPSIS Builds the run-level summary block (run id, SDDC Manager, versions, timestamps, counts). .DESCRIPTION The counts portion is Format-VcfCheckHtmlSummaryTile's static tile markup, matching Tools/vcf-check-ui.html's exported summary tiles - the run-metadata table above it (run id, SDDC Manager, versions, timestamps) has no equivalent in the browser report's export and stays PowerShell-report-specific. .PARAMETER Report The report object from ConvertTo-VcfCheckReportJson. .OUTPUTS [String] the summary block markup. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [PSObject]$Report ) $vcfVersionText = if ($Report.vcfVersion) { ConvertTo-VcfCheckHtmlEncoded -Value $Report.vcfVersion } else { '—' } $totalExecutionTimeText = '—' if ($Report.startedAt -and $Report.completedAt) { $totalExecutionTimeMs = ([DateTime]$Report.completedAt - [DateTime]$Report.startedAt).TotalMilliseconds if ($totalExecutionTimeMs -ge 0) { $totalExecutionTimeText = Format-VcfCheckDuration -Milliseconds $totalExecutionTimeMs } } $tilesHtml = Format-VcfCheckHtmlSummaryTile -Summary $Report.summary -Results @($Report.results) return @" <div> <h1>VCF Check Report</h1> <table> <tr><th>Run ID</th><td>$(ConvertTo-VcfCheckHtmlEncoded -Value $Report.runId)</td></tr> <tr><th>SDDC Manager</th><td>$(ConvertTo-VcfCheckHtmlEncoded -Value $Report.sddcManagerFqdn)</td></tr> <tr><th>VCF Version</th><td>$vcfVersionText</td></tr> <tr><th>Tool Version</th><td>$(ConvertTo-VcfCheckHtmlEncoded -Value $Report.toolVersion)</td></tr> <tr><th>Started</th><td>$(ConvertTo-VcfCheckHtmlEncoded -Value $Report.startedAt)</td></tr> <tr><th>Completed</th><td>$(ConvertTo-VcfCheckHtmlEncoded -Value $Report.completedAt)</td></tr> <tr><th>Total Execution Time</th><td>$totalExecutionTimeText</td></tr> </table> $tilesHtml </div> "@ } function Format-VcfCheckHtmlNav { <# .SYNOPSIS Builds the sidebar navigation, grouped by Area, one status-dot link per result. .DESCRIPTION Matches Tools/vcf-check-ui.html's buildExportNav() markup and classes exactly (.export-nav/.export-nav-group-title/.export-nav-link) - grouped by Area only, not nested by Domain, since nesting would make the sidebar deeply repetitive for a multi-domain run (the same check names repeating under every domain). A result whose Domain is non-empty gets "[Domain]" appended to its link label instead. .PARAMETER Results Array of VcfCheck.Result objects, in report order. .OUTPUTS [String] the <nav>...</nav> markup. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Results ) $areaGroups = [Ordered]@{} for ($index = 0; $index -lt $Results.Count; $index++) { $result = $Results[$index] $area = if ($result.Area) { $result.Area } else { 'Other' } if (-not $areaGroups.Contains($area)) { $areaGroups[$area] = [System.Collections.Generic.List[Object]]::new() } $areaGroups[$area].Add(@{ Result = $result; Index = $index }) } $sb = [System.Text.StringBuilder]::new() $null = $sb.Append('<nav class="export-nav"><a href="#top">↑ Summary</a>') foreach ($area in $areaGroups.Keys) { $null = $sb.Append('<div class="export-nav-group-title">').Append((ConvertTo-VcfCheckHtmlEncoded -Value $area)).Append('</div>') foreach ($entry in $areaGroups[$area]) { $result = $entry.Result $baseLabel = if ($result.DisplayName) { $result.DisplayName } else { $result.CheckId } $label = $baseLabel if ($result.Component) { $label = "$baseLabel [$($result.Component)]" } elseif ($result.Domain) { $label = "$baseLabel [$($result.Domain)]" } $null = $sb.Append('<a class="export-nav-link ').Append((ConvertTo-VcfCheckHtmlEncoded -Value $result.Status)).Append('" href="#check-').Append($entry.Index).Append('">').Append((ConvertTo-VcfCheckHtmlEncoded -Value $label)).Append('</a>') } } $null = $sb.Append('</nav>') return $sb.ToString() } function Format-VcfCheckHtmlSummaryTable { <# .SYNOPSIS Builds the run's summary table: one row per result, linking to its detail card. .PARAMETER Results Array of VcfCheck.Result objects, in report order. .OUTPUTS [String] the <table>...</table> markup. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Results ) $totalDurationMs = ($Results | Where-Object { $null -ne $_.DurationMs } | Measure-Object -Property DurationMs -Sum).Sum if (-not $totalDurationMs) { $totalDurationMs = 0 } $sb = [System.Text.StringBuilder]::new() $null = $sb.Append('<h2>Summary</h2><table class="rows-table"><tr><th>No.</th><th>Check</th><th>Area</th><th>Domain / Component</th><th>Domain Type</th><th>Status</th><th>Duration</th><th>%</th></tr>') for ($index = 0; $index -lt $Results.Count; $index++) { $result = $Results[$index] $statusClass = Get-VcfCheckHtmlStatusClass -Status $result.Status -Blocking ([Bool]$result.Blocking) $percent = 0 if ($totalDurationMs -gt 0 -and $result.DurationMs) { $percent = [Math]::Round(($result.DurationMs / $totalDurationMs) * 100, 1) } $duration = '' if ($null -ne $result.DurationMs) { $duration = Format-VcfCheckDuration -Milliseconds $result.DurationMs } $baseLabel = if ($result.DisplayName) { $result.DisplayName } else { $result.CheckId } $null = $sb.Append('<tr><td>').Append($index + 1).Append('</td><td><a href="#check-').Append($index).Append('">') $null = $sb.Append((ConvertTo-VcfCheckHtmlEncoded -Value $baseLabel)).Append('</a></td><td>') $null = $sb.Append((ConvertTo-VcfCheckHtmlEncoded -Value $result.Area)).Append('</td><td>') $domainOrComponent = if ($result.Component) { $result.Component } else { $result.Domain } $null = $sb.Append((ConvertTo-VcfCheckHtmlEncoded -Value $domainOrComponent)).Append('</td><td>') $null = $sb.Append((ConvertTo-VcfCheckHtmlEncoded -Value $result.DomainType)).Append('</td><td class="cell-').Append($statusClass).Append('">') $statusLabel = $result.Status if ($result.Status -eq 'Skipped' -and $result.Detail) { $statusLabel = "Skipped - $($result.Detail)" } $null = $sb.Append((ConvertTo-VcfCheckHtmlEncoded -Value $statusLabel)).Append('</td><td>') $null = $sb.Append((ConvertTo-VcfCheckHtmlEncoded -Value $duration)).Append('</td><td>').Append($percent).Append('%</td></tr>') } $null = $sb.Append('</table>') return $sb.ToString() } function ConvertTo-VcfCheckNormalizedExpiryDateText { <# .SYNOPSIS Reformats an expiration-date column's display text to YYYY-MM-DD. .DESCRIPTION Check functions emit expiration dates in several different formats (full ISO 8601, raw vendor-API strings, culture-default .NET DateTime text, "Never", free-text day counts). This normalizes only the report/UI display text for columns whose name looks like an expiration date (matches "Expir...Date", e.g. ExpiryDate, ExpirationDate, "Expiry Date"), a "Next Rotation" column, or a "Last Backup Date" column; the underlying JSON result is untouched since this runs on the already-stringified table cell, not the check's Rows object. A value that isn't a parseable date (e.g. "Never", or a "Last Backup Date" cell with a trailing "more than 48 hours ago." suffix) is returned unchanged. .PARAMETER ColumnName The row's property name for this cell, before camelCase-to-space label formatting. .PARAMETER Value The cell's already-stringified display text. .OUTPUTS [String] #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [String]$ColumnName, [Parameter(Mandatory = $true)] [AllowEmptyString()] [String]$Value ) if ($ColumnName -notmatch 'Expir.*Date|Next Rotation|Last Backup Date' -or [String]::IsNullOrWhiteSpace($Value)) { return $Value } $parsedDate = [DateTime]::MinValue $parsed = [DateTime]::TryParse( $Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$parsedDate ) if (-not $parsed) { return $Value } return $parsedDate.ToString('yyyy-MM-dd') } function Format-VcfCheckHtmlRowsTable { <# .SYNOPSIS Renders a check result's structured Rows as an HTML table with the "rows-table" class, matching Tools/vcf-check-ui.html's rowsToHtml()/renderRowsTable() output exactly. .DESCRIPTION Column headers are derived from the FIRST row's own property names, in that row's property order - every subsequent row reads that same fixed list of property names (a row missing one of those properties renders an empty cell; an extra property on a later row not present on the first is ignored) rather than shifting columns per row. This is a deliberate, documented behavior: a check's own Rows are expected to share one schema. Any cell whose trimmed value case-insensitively matches a known status word (Pass/Fail/Warning/Error/Skipped/GREEN/YELLOW/RED) gets the matching status CSS class, scoped by the "rows-table" class to the same --pass/--warning/--fail/--error/--skipped variables the browser report's rows tables use. A cell value that is a collection with more than one item (e.g. a check reporting several vLCM components for one cluster) renders as a bulleted list instead of being flattened to a single delimited string - a run-on "A; B; C" cell is hard to scan once a cluster has more than one or two entries. A single-item collection renders as plain text, matching every other cell. A column present on $Rows[0] but blank (null/empty/whitespace) on every row is dropped entirely, rather than rendering as an empty column - e.g. a "Message" property some checks only populate for unhealthy rows and leave $null everywhere else. A cell that is blank (null/empty/whitespace) on a row where the column is otherwise kept renders as "N/A" rather than an empty <td>, so a reviewer can distinguish "queried and found nothing" from a rendering gap. When $Rows has both a Severity and a Count column, rows are reordered by severity (ERROR, CRITICAL, HIGH, MEDIUM, LOW, WARNING, INFO, then anything else), then by descending Count, then by Summary alphabetically - matching Tools/ui/common.js's VcfCheckUI.sortRowsBySeverityCount(). .PARAMETER Rows Array of PSCustomObject "table rows". $null or empty returns an empty string (no <table> emitted at all). .OUTPUTS [String] the <table>...</table> markup, or an empty string when Rows is empty/absent. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $false)] [AllowNull()] [Object[]]$Rows = @() ) if (-not $Rows -or $Rows.Count -eq 0) { return '' } # $Rows[0] is expected to be a PSCustomObject "row" - if a caller instead handed a bare # string (e.g. a nested object flattened to text by a ConvertTo-Json -Depth cutoff upstream; # see Write-VcfCheckReport), $Rows[0].PSObject.Properties only exposes .Length, rendering # a single misleading "Length" column instead of the string's actual content. Fall back to a # single "Value" column so a caller passing malformed rows still gets a readable table. $columns = if ($Rows[0] -is [String]) { @('Value') } else { @($Rows[0].PSObject.Properties | Select-Object -ExpandProperty Name) | Where-Object { $column = $_ $Rows | Where-Object { $_ -isnot [String] -and $_.PSObject.Properties[$column] -and -not [String]::IsNullOrWhiteSpace([String]$_.PSObject.Properties[$column].Value) } } } $statusClasses = @{ 'PASS' = 'pass'; 'GREEN' = 'pass' 'WARNING' = 'warning'; 'YELLOW' = 'warning' 'FAIL' = 'fail'; 'RED' = 'fail' 'ERROR' = 'error' 'SKIPPED' = 'skipped' } $noTransformColumns = @('UsedPercent') # Mirrors Tools/ui/common.js's VcfCheckUI.sortRowsBySeverityCount(): when a Rows table has # both a Severity and a Count column (e.g. an alarm/event summary), group by severity in # ERROR/CRITICAL/HIGH/MEDIUM/LOW/WARNING/INFO order, then by descending Count, then by # Summary alphabetically. $severityOrder = @{ 'ERROR' = 0; 'CRITICAL' = 1; 'HIGH' = 2; 'MEDIUM' = 3; 'LOW' = 4; 'WARNING' = 5; 'INFO' = 6 } if ('Severity' -in $columns -and 'Count' -in $columns) { $Rows = @($Rows | Sort-Object -Property @( @{ Expression = { $rank = $severityOrder[([String]$_.Severity).Trim().ToUpperInvariant()]; if ($null -eq $rank) { 99 } else { $rank } } }, @{ Expression = { [Int64]$_.Count }; Descending = $true }, @{ Expression = { [String]$_.Summary } } )) } $sb = [System.Text.StringBuilder]::new() $null = $sb.Append('<table class="rows-table"><tr>') foreach ($column in $columns) { $label = if ($column -in $noTransformColumns) { $column } else { $column -creplace '([a-z])([A-Z])', '$1 $2' } if ($label -eq 'Capacity GB') { $label = 'Capacity (GB)' } if ($label -eq 'Host Name') { $label = 'Hostname/IP' } $null = $sb.Append('<th>').Append((ConvertTo-VcfCheckHtmlEncoded -Value $label)).Append('</th>') } $null = $sb.Append('</tr>') foreach ($row in $Rows) { $null = $sb.Append('<tr>') foreach ($column in $columns) { $cellValue = $null if ($row -is [String]) { if ($column -eq 'Value') { $cellValue = $row } } elseif ($row.PSObject.Properties[$column]) { $cellValue = $row.PSObject.Properties[$column].Value } $isMultiValueCell = ($cellValue -isnot [String]) -and ($cellValue -is [System.Collections.IEnumerable]) if ($isMultiValueCell) { $cellItems = @($cellValue) } if ($isMultiValueCell -and $cellItems.Count -gt 1) { $listHtml = [System.Text.StringBuilder]::new() $null = $listHtml.Append('<ul class="cell-list">') foreach ($item in $cellItems) { $itemText = ConvertTo-VcfCheckNormalizedExpiryDateText -ColumnName $column -Value ([String]$item) $null = $listHtml.Append('<li>').Append((ConvertTo-VcfCheckHtmlEncoded -Value $itemText)).Append('</li>') } $null = $listHtml.Append('</ul>') $null = $sb.Append('<td>').Append($listHtml.ToString()).Append('</td>') } else { $cellText = if ($isMultiValueCell) { [String]$cellItems[0] } else { [String]$cellValue } $cellText = ConvertTo-VcfCheckNormalizedExpiryDateText -ColumnName $column -Value $cellText if ([String]::IsNullOrWhiteSpace($cellText)) { $cellText = 'N/A' } $cellClass = $statusClasses[$cellText.Trim().ToUpperInvariant()] $classAttribute = '' if ($cellClass) { $classAttribute = " class=`"cell-$cellClass`"" } $null = $sb.Append('<td').Append($classAttribute).Append('>').Append((ConvertTo-VcfCheckHtmlEncoded -Value $cellText)).Append('</td>') } } $null = $sb.Append('</tr>') } $null = $sb.Append('</table>') return $sb.ToString() } function Format-VcfCheckHtmlHostDetailFieldValue { <# .SYNOPSIS Returns the display value for a host detail summary field, matching Tools/ui/common.js's hostDetailFieldValue(). .DESCRIPTION CpuCompatibility's and CpuDeprecationStatus' raw enum values, and CpuCompatibilityMatchedSeries'/ CpuDeprecationMatchedSeries' "no match" case, all need friendlier display text than the raw HostDetails value. .PARAMETER FieldName The PSCustomObject property name (e.g. 'CpuCompatibility'). .PARAMETER Value The raw property value. .OUTPUTS [String] the display value. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [String]$FieldName, [Parameter(Mandatory = $false)] [AllowNull()] [Object]$Value ) if ($null -eq $Value -or ($Value -is [String] -and [string]::IsNullOrEmpty($Value))) { if ($FieldName -in @('CpuCompatibilityMatchedSeries', 'CpuDeprecationMatchedSeries')) { return 'N/A' } return '' } if ($FieldName -eq 'CpuCompatibility' -and $Value -eq 'NotListed') { return 'Not Supported' } if ($FieldName -eq 'CpuDeprecationStatus' -and $Value -eq 'None') { return 'Not Deprecated' } return $Value } function Format-VcfCheckHtmlHostSummaryBar { <# .SYNOPSIS Renders the "Hosts" summary bar shown above a check result's per-host detail cards, matching Tools/ui/common.js's VcfCheckUI.renderHostSummaryBar() markup exactly. .DESCRIPTION Tallies each host's CPU status via Get-VcfCheckHtmlHostCpuStatus and renders a total host-count pill alongside a compatible/deprecated/unsupported pill for each non-zero count, so an operator can see the overall CPU compatibility mix without expanding any cluster or host card. .PARAMETER HostDetails Array of per-host PSCustomObjects. .OUTPUTS [String] the "host-summary-bar" markup. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [Object[]]$HostDetails ) $counts = [Ordered]@{ Compatible = 0; Deprecated = 0; Unsupported = 0 } foreach ($hostDetail in $HostDetails) { $cpuStatus = Get-VcfCheckHtmlHostCpuStatus -HostDetail $hostDetail if ($cpuStatus) { $counts[$cpuStatus]++ } } $hasCpuCounts = ($counts.Compatible + $counts.Deprecated + $counts.Unsupported) -gt 0 $sb = [System.Text.StringBuilder]::new() $null = $sb.Append('<div class="host-summary-bar"><span class="host-summary-label">Hosts</span>') $totalBadgeClass = if ($hasCpuCounts) { 'cluster-badge neutral' } else { 'cluster-badge' } $totalLabel = "$($HostDetails.Count) host$(if ($HostDetails.Count -eq 1) { '' } else { 's' })" $null = $sb.Append('<span class="').Append($totalBadgeClass).Append('">').Append($totalLabel).Append('</span>') foreach ($status in @('Compatible', 'Deprecated', 'Unsupported')) { if ($counts[$status] -eq 0) { continue } $statusClass = $status.ToLowerInvariant() $null = $sb.Append('<span class="cluster-badge ').Append($statusClass).Append('">').Append($counts[$status]).Append(' ').Append($statusClass).Append('</span>') } $null = $sb.Append('</div>') return $sb.ToString() } function Format-VcfCheckHtmlHostDetailCard { <# .SYNOPSIS Renders a check result's per-host structured data as one collapsible <details> section per host, matching Tools/vcf-check-ui.html's hostDetailsToHtml() markup exactly. .DESCRIPTION Unlike Format-VcfCheckHtmlRowsTable (one flat table for the whole result), each host gets its own native HTML <details class="host-details"> block - collapsed by default, no JavaScript required - so a report with many hosts stays scannable. A host's own hardware summary fields (any property whose value is not itself an array) render as a plain key/value table; array-valued properties (NetworkAdapters, StorageAdapters, StorageDevices, etc.) each render as their own "rows-table" sub-table via Format-VcfCheckHtmlRowsTable, using the property name (space-separated from PascalCase) as its heading. .PARAMETER HostDetails Array of per-host PSCustomObjects. $null or empty returns an empty string (no markup emitted at all). Must include a HostName property, used as the <summary> label. .OUTPUTS [String] the concatenated <details>...</details> markup, or an empty string when HostDetails is empty/absent. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $false)] [AllowNull()] [Object[]]$HostDetails = @() ) if (-not $HostDetails -or $HostDetails.Count -eq 0) { return '' } $sb = [System.Text.StringBuilder]::new() $null = $sb.Append((Format-VcfCheckHtmlHostSummaryBar -HostDetails $HostDetails)) foreach ($hostDetail in $HostDetails) { $summaryFields = [Ordered]@{} $arrayFields = [Ordered]@{} $clusterName = $null foreach ($property in $hostDetail.PSObject.Properties) { $propName = $property.Name $propValue = $property.Value if ($propName -eq 'HostName') { continue } if ($propName -eq 'ClusterName') { $clusterName = if ($propValue) { $propValue } else { $null } continue } if ($propValue -is [Array]) { $arrayFields[$propName] = @($propValue) } else { $summaryFields[$propName] = $propValue } } $summaryLabel = (ConvertTo-VcfCheckHtmlEncoded -Value $hostDetail.HostName) if ($clusterName -and -not [string]::IsNullOrWhiteSpace($clusterName)) { $summaryLabel += ' <span class="cluster-badge">' + (ConvertTo-VcfCheckHtmlEncoded -Value $clusterName) + '</span>' } $cpuStatus = Get-VcfCheckHtmlHostCpuStatus -HostDetail $hostDetail if ($cpuStatus) { $cpuStatusLabel = @{ Compatible = 'Compatible CPU'; Deprecated = 'Deprecated CPU'; Unsupported = 'Unsupported CPU' }[$cpuStatus] $summaryLabel += ' <span class="cpu-status-badge ' + $cpuStatus.ToLowerInvariant() + '">' + $cpuStatusLabel + '</span>' } $null = $sb.Append('<details class="host-details"><summary>').Append($summaryLabel).Append('</summary><div class="host-details-body">') $null = $sb.Append('<table class="host-detail-summary">') foreach ($fieldName in $summaryFields.Keys) { $label = if ($fieldName -eq 'CpuCompatibility') { 'VCF 9.1 CPU Compatibility' } else { ($fieldName -creplace '([a-z])([A-Z])', '$1 $2') -creplace '\bCpu\b', 'CPU' } $value = Format-VcfCheckHtmlHostDetailFieldValue -FieldName $fieldName -Value $summaryFields[$fieldName] $null = $sb.Append('<tr><th>').Append((ConvertTo-VcfCheckHtmlEncoded -Value $label)).Append('</th><td>') $null = $sb.Append((ConvertTo-VcfCheckHtmlEncoded -Value $value)).Append('</td></tr>') } $null = $sb.Append('</table>') foreach ($fieldName in $arrayFields.Keys) { $subTable = Format-VcfCheckHtmlRowsTable -Rows $arrayFields[$fieldName] if ($subTable) { $label = ($fieldName -creplace '([a-z])([A-Z])', '$1 $2') -creplace '\bCpu\b', 'CPU' $null = $sb.Append('<h4>').Append((ConvertTo-VcfCheckHtmlEncoded -Value $label)).Append('</h4>').Append($subTable) } } $null = $sb.Append('</div></details>') } return $sb.ToString() } function Get-VcfCheckHtmlHostCpuStatus { <# .SYNOPSIS Classifies a host's CPU as Compatible/Deprecated/Unsupported for the "Compatible/Deprecated/ Unsupported CPU" badge, matching Tools/ui/common.js's hostCpuStatus() exactly. .PARAMETER HostDetail A single per-host PSCustomObject, as passed to Format-VcfCheckHtmlHostDetailCard. .OUTPUTS [String] 'Compatible', 'Deprecated', or 'Unsupported'; $null if the host has neither a CpuCompatibility nor a CpuDeprecationStatus property (i.e. this is not the CPU check). #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [Object]$HostDetail ) if ($HostDetail.CpuCompatibility -eq 'NotListed' -or $HostDetail.CpuDeprecationStatus -eq 'Discontinued') { return 'Unsupported' } if ($HostDetail.CpuDeprecationStatus -eq 'Deprecated') { return 'Deprecated' } if ($HostDetail.CpuCompatibility -eq 'Compatible') { return 'Compatible' } return $null } function Format-VcfCheckHtmlResultCardDetail { <# .SYNOPSIS Builds the dt/dd field pairs inside one result row's <dl class="result-detail">, matching Tools/vcf-check-ui.html's resultToHtml() field list and order exactly. .DESCRIPTION Order is Target, Destination, Detail, Validation Criteria, Remediation, Exception, then a Results entry for any structured Rows table and a Hosts (or HostDetailsLabel) entry for any HostDetails - the same list resultToHtml() builds and filters. A field whose value is null/empty/whitespace is omitted entirely. Remediation is only shown for Fail/Warning/ Error statuses, and Validation Criteria is omitted for Skipped results, mirroring resultToHtml()'s showRemediation/isSkipped checks. .PARAMETER Result The VcfCheck.Result object. .OUTPUTS [String] the concatenated <dt>/<dd> pairs. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [PSObject]$Result ) # ESX/vSAN/Tanzu-area checks run per-vCenter and their TargetComponent is always a vCenter # FQDN, not an ESX host or vSAN cluster - see New-VcfCheckPerDomainResults in # Connections.ps1. $targetTypeLabels = @{ 'ESX' = 'vCenter'; 'vSAN' = 'vCenter'; 'vCenter' = 'vCenter'; 'Tanzu' = 'vCenter' 'NSX' = 'NSX Manager'; 'SDDC Manager' = 'SDDC Manager'; 'Aria Suite' = 'Aria Suite Lifecycle Manager' } # A couple of SDDC-Manager-area checks route through the same per-vCenter merge helpers as # the ESX/vSAN checks above, so their TargetComponent is a vCenter FQDN too, despite the # "SDDC Manager" area - the Area-based table alone would mislabel these as targeting SDDC # Manager itself. $targetTypeOverridesByCheckId = @{ 'sddc_check_vlcm_vum' = 'vCenter' 'sddc_cluster_resource_utilization' = 'vCenter' 'sddc_check_cores_and_vsan_tib' = 'vCenter' 'aria_ops_adapter_collection_status' = 'Aria Operations' 'aria_ops_collector_status' = 'Aria Operations' 'aria_ops_critical_alerts' = 'Aria Operations' 'aria_ops_certificate_expiration' = 'Aria Operations' 'aria_ops_collector_type' = 'Aria Operations' 'aria_ops_lifecycle_status' = 'Aria Operations' 'aria_ops_license' = 'Aria Operations' 'aria_ops_sizing_overview' = 'Aria Operations' } $targetComponentText = $Result.TargetComponent if (-not [String]::IsNullOrWhiteSpace($targetComponentText)) { $targetTypeLabel = $targetTypeOverridesByCheckId[$Result.CheckId] if (-not $targetTypeLabel -and $targetTypeLabels.ContainsKey($Result.Area)) { $targetTypeLabel = $targetTypeLabels[$Result.Area] } if ($targetTypeLabel) { $targetComponentText = "${targetTypeLabel}: $targetComponentText" } } $isSkipped = $Result.Status -eq 'Skipped' $showRemediation = $Result.Status -in @('Fail', 'Warning', 'Error') $fields = [Ordered]@{ 'Target' = $targetComponentText 'Destination' = $Result.Destination 'Detail' = $Result.Detail } if (-not $isSkipped) { $fields['Validation Criteria'] = $Result.ValidationCriteria } if ($showRemediation) { $fields['Remediation'] = $Result.Remediation } $fields['Exception'] = $Result.Exception $sb = [System.Text.StringBuilder]::new() foreach ($fieldName in $fields.Keys) { $fieldValue = $fields[$fieldName] if ([String]::IsNullOrWhiteSpace([String]$fieldValue)) { continue } $encodedFieldValue = if ($fieldName -eq 'Remediation') { ConvertTo-VcfCheckHtmlRemediationEncoded -Value $fieldValue } else { ConvertTo-VcfCheckHtmlEncoded -Value $fieldValue } $null = $sb.Append('<dt>').Append((ConvertTo-VcfCheckHtmlEncoded -Value $fieldName)).Append('</dt><dd>').Append($encodedFieldValue).Append('</dd>') } $rowsTable = Format-VcfCheckHtmlRowsTable -Rows $Result.Rows if ($rowsTable) { $null = $sb.Append('<dt>Results</dt><dd>').Append($rowsTable).Append('</dd>') } $hostDetailCards = Format-VcfCheckHtmlHostDetailCard -HostDetails $Result.HostDetails if ($hostDetailCards) { $hostDetailsLabel = if ($Result.HostDetailsLabel) { $Result.HostDetailsLabel } else { 'Hosts' } $null = $sb.Append('<dt>').Append((ConvertTo-VcfCheckHtmlEncoded -Value $hostDetailsLabel)).Append('</dt><dd>').Append($hostDetailCards).Append('</dd>') } return $sb.ToString() } function Format-VcfCheckHtmlResultCard { <# .SYNOPSIS Builds one result's row: badge/name/area header plus its Detail/Exception/Rows/host fields, matching Tools/vcf-check-ui.html's exported resultToHtml() markup and classes exactly (.result-row/.result-summary/.badge/.result-name/.result-area/.result-detail). .DESCRIPTION Wrapped in a native <details>/<summary> pair (in place of resultToHtml()'s always-open <dl class="result-detail open">) so the row still collapses/expands with no JavaScript - per the HTML living-standard "revealing algorithm" a browser auto-expands it when the nav (Format-VcfCheckHtmlNav) or summary table (Format-VcfCheckHtmlSummaryTable) links to its #check-<Index> anchor. .PARAMETER Result The VcfCheck.Result object. .PARAMETER Index The result's position in the report's results array - used as the row's anchor id (#check-<Index>), since a per-domain split can repeat the same CheckId across several results, so CheckId alone cannot serve as a unique anchor. .OUTPUTS [String] the <details>...</details> markup for this one result. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [PSObject]$Result, [Parameter(Mandatory = $true)] [Int]$Index ) $baseLabel = if ($Result.DisplayName) { $Result.DisplayName } else { $Result.CheckId } $duration = '' if ($null -ne $Result.DurationMs) { $duration = Format-VcfCheckDuration -Milliseconds $Result.DurationMs } $domainHtml = '' if ($Result.Component) { $domainHtml = " <span class=`"result-domain result-domain-component`">Component: $(ConvertTo-VcfCheckHtmlEncoded -Value $Result.Component)</span>" } elseif ($Result.Domain) { $domainModifier = '' if ($Result.DomainType -eq 'MANAGEMENT') { $domainModifier = ' result-domain-management' } elseif ($Result.DomainType) { $domainModifier = ' result-domain-workload' } $domainHtml = " <span class=`"result-domain$domainModifier`">Domain: $(ConvertTo-VcfCheckHtmlEncoded -Value $Result.Domain)</span>" } $infoOnlyHtml = '' if ($Result.Informational) { $infoOnlyHtml = ' <span class="result-info-only">Info-only</span>' } $skipReasonHtml = '' if ($Result.Status -eq 'Skipped' -and $Result.Detail) { $skipReasonHtml = " <span class=`"result-skip-reason`">Skipped Reason: $(ConvertTo-VcfCheckHtmlEncoded -Value $Result.Detail)</span>" } $detailsHtml = Format-VcfCheckHtmlResultCardDetail -Result $Result $sb = [System.Text.StringBuilder]::new() $null = $sb.Append('<details class="result-row" id="check-').Append($Index).Append('"><summary class="result-summary">') $null = $sb.Append('<span class="badge ').Append((ConvertTo-VcfCheckHtmlEncoded -Value $Result.Status)).Append('">').Append((ConvertTo-VcfCheckHtmlEncoded -Value $Result.Status)).Append('</span>') $null = $sb.Append('<span class="result-name"><span class="result-name-text">').Append((ConvertTo-VcfCheckHtmlEncoded -Value $baseLabel)).Append('</span>') $null = $sb.Append(' <span class="result-area">(').Append((ConvertTo-VcfCheckHtmlEncoded -Value $Result.Area)).Append(')</span>').Append($domainHtml).Append($infoOnlyHtml).Append($skipReasonHtml).Append('</span>') $null = $sb.Append('</summary><dl class="result-detail open">').Append($detailsHtml).Append('</dl>') if ($duration) { $null = $sb.Append('<div class="check-duration-badge">').Append((ConvertTo-VcfCheckHtmlEncoded -Value "$duration to execute")).Append('</div>') } $null = $sb.Append('</details>') return $sb.ToString() } function Format-VcfCheckHtmlSkipGroup { <# .SYNOPSIS Wraps every Skipped result's row inside one collapsed-by-default group, so a run with many skipped checks (e.g. optional components not deployed) doesn't push the checks a reader actually cares about further down the page. .DESCRIPTION Uses the same .skip-group/.skip-group-count/.skip-group-body classes Tools/vcf-check-ui.html defines for its live-view renderSkipGroupRow (the exported HTML file has no skip grouping at all - it lists every result inline), wrapped in a native <details>/<summary> pair for a script-free collapse/expand, same pattern as Format-VcfCheckHtmlResultCard. Each skipped result keeps its own #check-<Index> anchor and remains its own nested Format-VcfCheckHtmlResultCard row - the nav and summary table links still work, since a browser auto-expands every ancestor <details> up to a same-page anchor target. .PARAMETER SkippedResults Array of (Result, Index) pairs, each a Hashtable with keys Result and Index, for every result whose Status is Skipped, in report order. .OUTPUTS [String] the <details class="skip-group">...</details> markup, or an empty string when SkippedResults is empty. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$SkippedResults ) if ($SkippedResults.Count -eq 0) { return '' } $countLabel = "$($SkippedResults.Count) check$(if ($SkippedResults.Count -ne 1) { 's' }) skipped" $sb = [System.Text.StringBuilder]::new() $null = $sb.Append('<details class="result-row skip-group"><summary class="result-summary">') $null = $sb.Append('<span class="result-name"><span class="result-name-text">Skipped Checks</span></span>') $null = $sb.Append('<span class="skip-group-count">').Append((ConvertTo-VcfCheckHtmlEncoded -Value $countLabel)).Append('</span></summary>') $null = $sb.Append('<div class="skip-group-body open">') foreach ($entry in $SkippedResults) { $null = $sb.Append((Format-VcfCheckHtmlResultCard -Result $entry.Result -Index $entry.Index)) } $null = $sb.Append('</div></details>') return $sb.ToString() } function Format-VcfCheckHtmlDocument { <# .SYNOPSIS Assembles the full static HTML document for a precheck run report. .DESCRIPTION Wraps <main> (run summary/tiles/results) and Format-VcfCheckHtmlNav's <nav> in a div#top.export-layout, matching Tools/vcf-check-ui.html's buildHtmlExportContent() body structure and CSS ordering exactly, so the two reports lay out identically. .PARAMETER Report The report object from ConvertTo-VcfCheckReportJson. .PARAMETER Title Document title, not yet HTML-encoded - this function encodes it once for the <title>. .PARAMETER Theme Either 'light' or 'dark'. Matches Tools/vcf-check-ui.html's own body.light CSS toggle so the static report opens in the same theme the user last chose there. Defaults to 'dark', the CSS default when no body class is present. .OUTPUTS [String] the complete HTML document. #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [PSObject]$Report, [Parameter(Mandatory = $true)] [String]$Title, [Parameter(Mandatory = $false)] [ValidateSet('light', 'dark')] [String]$Theme = 'dark' ) $results = @($Report.results) $encodedTitle = ConvertTo-VcfCheckHtmlEncoded -Value $Title $bodyClass = if ($Theme -eq 'light') { ' class="light"' } else { '' } $sb = [System.Text.StringBuilder]::new() $null = $sb.Append('<!DOCTYPE html><html lang="en">') $null = $sb.Append((Format-VcfCheckHtmlDocumentHead -Title $encodedTitle)) $null = $sb.Append('<body').Append($bodyClass).Append('><div id="top" class="export-layout"><main>') $null = $sb.Append((Format-VcfCheckHtmlRunSummary -Report $Report)) $null = $sb.Append((Format-VcfCheckHtmlSummaryTable -Results $results)) $null = $sb.Append('<h2>Check Details</h2><div id="results">') # Skipped results are pulled out of the main flow and rendered once, together, inside a # single collapsed group at the end - see Format-VcfCheckHtmlSkipGroup. Original array # index is preserved per result (not renumbered) so #check-<Index> anchors from the nav and # summary table still resolve to the right row regardless of where it landed in the DOM. $skippedEntries = [System.Collections.Generic.List[Object]]::new() for ($index = 0; $index -lt $results.Count; $index++) { $result = $results[$index] if ($result.Status -eq 'Skipped') { $skippedEntries.Add(@{ Result = $result; Index = $index }) } else { $null = $sb.Append((Format-VcfCheckHtmlResultCard -Result $result -Index $index)) } } $null = $sb.Append((Format-VcfCheckHtmlSkipGroup -SkippedResults $skippedEntries)) $null = $sb.Append('</div></main>') $null = $sb.Append((Format-VcfCheckHtmlNav -Results $results)) $null = $sb.Append('</div></body></html>') return $sb.ToString() } function Export-VcfCheckReportHtml { <# .SYNOPSIS Writes a self-contained, static HTML report for a precheck run - no server required. .DESCRIPTION Consumes the same $Report object ConvertTo-VcfCheckReportJson already produces (run id, SDDC Manager FQDN, vcfVersion, summary, results[]), so there is exactly one source of truth for report data rather than a second parallel data-shaping path. Composed from small Format-VcfCheckHtml* helpers (nav/summary/rows/cards), each independently testable, whose markup and CSS classes mirror Tools/vcf-check-ui.html's own exported HTML report exactly. Every dynamic value is routed through ConvertTo-VcfCheckHtmlEncoded before being interpolated. Deliberately has no <script> element at all (pure CSS plus native <details>/<summary> disclosure and anchor-link navigation) - a static, open-once artifact has no need for it, and omitting it removes an entire class of DOM-injection risk outright rather than mitigating it. .PARAMETER Report The report object from ConvertTo-VcfCheckReportJson. .PARAMETER Path Destination .html file path. Parent directory is created if missing. .PARAMETER Title Optional document title override. Defaults to "VCF Check Report - <SddcManagerFqdn> - <RunId>". .PARAMETER Theme Either 'light' or 'dark'. When omitted, resolved from the browser's saved preference via Get-VcfCheckThemePreference so this static report opens in the same theme as the browser report, rather than always falling back to the CSS default (dark). .OUTPUTS [String] the path written. .EXAMPLE Export-VcfCheckReportHtml -Report $report -Path "$env:VcfCheckBaseDirectory\Findings\report.html" #> [CmdletBinding()] [OutputType([String])] Param ( [Parameter(Mandatory = $true)] [PSObject]$Report, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Path, [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Title = '', [Parameter(Mandatory = $false)] [ValidateSet('', 'light', 'dark')] [String]$Theme = '' ) $parentDirectory = Split-Path -Parent $Path if ($parentDirectory -and -not (Test-Path -LiteralPath $parentDirectory -PathType Container)) { $null = New-Item -ItemType Directory -Path $parentDirectory -Force } $resolvedTitle = $Title if ([String]::IsNullOrWhiteSpace($resolvedTitle)) { $resolvedTitle = "VCF Check Report - $($Report.sddcManagerFqdn) - $($Report.runId)" } $resolvedTheme = $Theme if ([String]::IsNullOrWhiteSpace($resolvedTheme)) { $resolvedTheme = Get-VcfCheckThemePreference } $html = Format-VcfCheckHtmlDocument -Report $Report -Title $resolvedTitle -Theme $resolvedTheme Set-Content -LiteralPath $Path -Value $html -ErrorAction Stop -Encoding utf8 return $Path } #endregion Reporting |