Public/New-AzureLocalFleetStatusHtmlReport.ps1

function New-AzureLocalFleetStatusHtmlReport {
    <#
    .SYNOPSIS
        Generates a self-contained HTML report of fleet update status.
     
    .DESCRIPTION
        Collects update status data from Azure Local clusters and generates a standalone
        HTML report suitable for email, SharePoint, or offline viewing. The report includes
        executive summary cards, a progress bar, cluster status table with color-coded badges,
        and optional sections for health check failures and update run history.
         
        Data is collected using the module's existing functions:
        - Get-AzureLocalClusterInventory (cluster list and UpdateRing tags)
        - Get-AzureLocalClusterUpdateReadiness (health state, readiness)
        - Get-AzureLocalUpdateSummary (current update versions)
        - Get-AzureLocalAvailableUpdates (pending updates)
        - Get-AzureLocalUpdateRuns (recent update history, optional)
        - Test-AzureLocalClusterHealth (detailed health checks, optional)
     
    .PARAMETER ClusterResourceIds
        An array of full Azure Resource IDs for the clusters to include in the report.
     
    .PARAMETER ClusterNames
        An array of Azure Local cluster names to include in the report.
     
    .PARAMETER ScopeByUpdateRingTag
        Find clusters by their 'UpdateRing' tag value via Azure Resource Graph.
     
    .PARAMETER UpdateRingValue
        The value of the 'UpdateRing' tag to match when using -ScopeByUpdateRingTag.
     
    .PARAMETER ResourceGroupName
        Resource group containing the clusters (only used with -ClusterNames).
     
    .PARAMETER SubscriptionId
        Azure subscription ID (defaults to current subscription).
     
    .PARAMETER AllClusters
        Discovers all Azure Local clusters via Azure Resource Graph and includes them
        in the report. By default, no cap is applied - every discovered cluster is included.
        Use -MaxClusters to limit the number of clusters returned (e.g. for targeted runs
        or to avoid large fan-out). Uses the current Azure CLI subscription context.
 
    .PARAMETER MaxClusters
        Optional cap on clusters included when -AllClusters is used. Default 0 (no cap).
        Set to a positive integer (1-100000) to limit the fleet slice. Has no effect for
        other parameter sets.
     
    .PARAMETER OutputPath
        File path for the HTML report output. Required.
     
    .PARAMETER IncludeUpdateRuns
        Include recent update run history section in the report.
     
    .PARAMETER IncludeHealthDetails
        Include detailed health check failure section in the report.
     
    .PARAMETER Title
        Custom report title. Auto-generated if not specified:
        single cluster = '<ClusterName> - Update Status Report',
        multiple clusters = 'Azure Local Fleet Update Status Report'.
     
    .PARAMETER PassThru
        Returns the HTML content as a string in addition to writing the file.
     
    .OUTPUTS
        System.String - HTML content (only when -PassThru is specified).
     
    .EXAMPLE
        New-AzureLocalFleetStatusHtmlReport -AllClusters -OutputPath "C:\Reports\fleet-all.html"
        Generates an HTML report for all clusters (up to 100) across the subscription.
     
    .EXAMPLE
        New-AzureLocalFleetStatusHtmlReport -ScopeByUpdateRingTag -UpdateRingValue "Wave1" -OutputPath "C:\Reports\wave1-status.html"
        Generates an HTML report for all Wave1 clusters.
     
    .EXAMPLE
        New-AzureLocalFleetStatusHtmlReport -ClusterNames @("Cluster01","Cluster02") -OutputPath "C:\Reports\fleet.html" -IncludeHealthDetails -IncludeUpdateRuns
        Generates a full report with health details and update run history.
     
    .EXAMPLE
        $html = New-AzureLocalFleetStatusHtmlReport -ScopeByUpdateRingTag -UpdateRingValue "Production" -OutputPath "C:\Reports\prod.html" -PassThru
        Generates the report and also captures the HTML string for further use (e.g., email body).
    #>

    [CmdletBinding(DefaultParameterSetName = 'All', SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'ByResourceId')]
        [string[]]$ClusterResourceIds,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByName')]
        [string[]]$ClusterNames,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByTag')]
        [switch]$ScopeByUpdateRingTag,

        [ValidatePattern('^[A-Za-z0-9_-]{1,64}$')]
        [Parameter(Mandatory = $true, ParameterSetName = 'ByTag')]
        [string]$UpdateRingValue,

        [Parameter(Mandatory = $false, ParameterSetName = 'All')]
        [switch]$AllClusters,

        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [string]$ResourceGroupName,

        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [string]$SubscriptionId,

        [Parameter(Mandatory = $true)]
        [ValidateScript({
            $parent = Split-Path $_ -Parent
            if ($parent -and -not (Test-Path $parent)) {
                # Check if the drive at least exists
                $drive = Split-Path $_ -Qualifier -ErrorAction SilentlyContinue
                if ($drive -and -not (Test-Path $drive)) {
                    throw "Drive '$drive' does not exist. Check the output path."
                }
            }
            if ($_ -notmatch '\.html?$') {
                throw "OutputPath must end with .html extension."
            }
            $true
        })]
        [string]$OutputPath,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeUpdateRuns,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeHealthDetails,

        [Parameter(Mandatory = $false)]
        [string]$Title = "",

        [Parameter(Mandatory = $false)]
        [PSCustomObject]$StatusData,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 8)]
        [int]$ThrottleLimit = 4,

        # Optional cap on clusters returned by -AllClusters discovery. Default 0 (no cap).
        [Parameter(Mandatory = $false, ParameterSetName = 'All')]
        [ValidateRange(0, 100000)]
        [int]$MaxClusters = 0,

        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )

    Write-Log -Message "" -Level Info
    Write-Log -Message "========================================" -Level Header
    Write-Log -Message "Fleet Status HTML Report Generation" -Level Header
    Write-Log -Message "========================================" -Level Header

    # Load System.Web for HtmlEncode (XSS protection)
    Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue

    # Verify Azure CLI
    Test-AzCliAvailable | Out-Null
    try {
        $null = az account show 2>$null
        if ($LASTEXITCODE -ne 0) { throw "Not logged in" }
        Write-Log -Message "Azure CLI authentication verified" -Level Success
    }
    catch {
        Write-Log -Message "Azure CLI is not logged in. Please run 'az login' first." -Level Error
        return
    }

    # If pre-collected StatusData is provided, skip all API calls and go straight to rendering
    if ($StatusData) {
        Write-Log -Message "Using pre-collected StatusData ($($StatusData.TotalClusters) clusters, collected $($StatusData.Timestamp))" -Level Info
        $readiness = @($StatusData.Readiness)
        $clusterDetails = @($StatusData.ClusterDetails)
        $latestRuns = @($StatusData.LatestRuns)
        $healthResults = @($StatusData.HealthResults)
        [array]$updateRuns = @()
        if ($IncludeUpdateRuns) { [array]$updateRuns = @($latestRuns) }
    }
    else {
        # Collect data via Get-AzureLocalFleetStatusData (single-pass, parallel-capable)
        $collectParams = @{ ThrottleLimit = $ThrottleLimit }
        if ($IncludeUpdateRuns) { $collectParams['IncludeUpdateRuns'] = $true }
        if ($IncludeHealthDetails) { $collectParams['IncludeHealthDetails'] = $true }

        switch ($PSCmdlet.ParameterSetName) {
            'ByTag'        { $collectParams['ScopeByUpdateRingTag'] = $true; $collectParams['UpdateRingValue'] = $UpdateRingValue }
            'ByResourceId' { $collectParams['ClusterResourceIds'] = $ClusterResourceIds }
            'ByName'       {
                $collectParams['ClusterNames'] = $ClusterNames
                if ($ResourceGroupName) { $collectParams['ResourceGroupName'] = $ResourceGroupName }
                if ($SubscriptionId) { $collectParams['SubscriptionId'] = $SubscriptionId }
            }
            'All'          { $collectParams['AllClusters'] = $true; $collectParams['MaxClusters'] = $MaxClusters }
        }

        $StatusData = Get-AzureLocalFleetStatusData @collectParams
        if (-not $StatusData) {
            Write-Log -Message "No data collected. Cannot generate report." -Level Warning
            return
        }

        $readiness = @($StatusData.Readiness)
        $clusterDetails = @($StatusData.ClusterDetails)
        $latestRuns = @($StatusData.LatestRuns)
        $healthResults = @($StatusData.HealthResults)
        [array]$updateRuns = @()
        if ($IncludeUpdateRuns) { [array]$updateRuns = @($latestRuns) }
    }

    if ($readiness.Count -eq 0) {
        Write-Log -Message "No clusters found. Cannot generate report." -Level Warning
        return
    }

    # Auto-generate title if not explicitly provided
    if ([string]::IsNullOrWhiteSpace($Title)) {
        if ($readiness.Count -eq 1) {
            $Title = "$($readiness[0].ClusterName) - Update Status Report"
        }
        else {
            $Title = "Azure Local Fleet Update Status Report"
        }
    }

    #--- Calculate summary statistics ---
    $totalClusters = $readiness.Count
    $upToDate   = @($readiness | Where-Object { $_.UpdateState -in @("UpToDate", "AppliedSuccessfully") }).Count
    $inProgress = @($readiness | Where-Object { $_.UpdateState -eq "UpdateInProgress" }).Count
    $updateAvailable = @($readiness | Where-Object { $_.ReadyForUpdate -eq $true }).Count
    $healthFailures  = @($readiness | Where-Object { $_.HealthState -eq "Failure" }).Count
    $otherCount = [math]::Max(0, $totalClusters - $upToDate - $inProgress - $updateAvailable - $healthFailures)

    $pctUpToDate   = if ($totalClusters -gt 0) { [math]::Round(($upToDate / $totalClusters) * 100, 1) } else { 0 }
    $pctInProgress = if ($totalClusters -gt 0) { [math]::Round(($inProgress / $totalClusters) * 100, 1) } else { 0 }
    $pctAvailable  = if ($totalClusters -gt 0) { [math]::Round(($updateAvailable / $totalClusters) * 100, 1) } else { 0 }
    $pctFailures   = if ($totalClusters -gt 0) { [math]::Round(($healthFailures / $totalClusters) * 100, 1) } else { 0 }
    $pctOther      = if ($totalClusters -gt 0) { [math]::Round(($otherCount / $totalClusters) * 100, 1) } else { 0 }

    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $scopeDescription = if ($StatusData.Scope) { $StatusData.Scope } else {
        switch ($PSCmdlet.ParameterSetName) {
            'ByTag'        { "UpdateRing = $UpdateRingValue" }
            'ByResourceId' { "$($ClusterResourceIds.Count) cluster(s) by Resource ID" }
            'ByName'       { "$($ClusterNames.Count) cluster(s) by name" }
            'All'          { "All clusters ($totalClusters)" }
        }
    }

    #--- Build cluster identity section HTML (used at top or bottom depending on count) ---
    $clusterIdentityHtml = [System.Text.StringBuilder]::new()
    $identitySectionTitle = if ($clusterDetails.Count -le 10) { "Cluster Information" } else { "Appendix: Cluster Information" }
    [void]$clusterIdentityHtml.Append(@"
 
    <div class="section">
        <h2>$([System.Web.HttpUtility]::HtmlEncode($identitySectionTitle))</h2>
        <table>
            <thead>
                <tr>
                    <th>Cluster Name</th>
                    <th>Current Version</th>
                    <th>Current SBE Version</th>
                    <th>Node Count</th>
                    <th>Resource Group</th>
                    <th>Resource ID</th>
                </tr>
            </thead>
            <tbody>
"@
)
    foreach ($detail in $clusterDetails) {
        $encDetailName    = [System.Web.HttpUtility]::HtmlEncode($detail.ClusterName)
        $encDetailVersion = [System.Web.HttpUtility]::HtmlEncode($detail.CurrentVersion)
        $sbeValue         = if ($detail.PSObject.Properties['CurrentSbeVersion'] -and $detail.CurrentSbeVersion) { $detail.CurrentSbeVersion } else { 'N/A' }
        $encDetailSbe     = [System.Web.HttpUtility]::HtmlEncode($sbeValue)
        $encDetailNodes   = [System.Web.HttpUtility]::HtmlEncode($detail.NodeCount)
        $encDetailRG      = [System.Web.HttpUtility]::HtmlEncode($detail.ResourceGroup)
        $encDetailRID     = [System.Web.HttpUtility]::HtmlEncode($detail.ResourceId)
        $detailPortalUrl  = if ($detail.ResourceId) { [System.Web.HttpUtility]::HtmlEncode("https://portal.azure.com/#@/resource$($detail.ResourceId)") } else { "" }

        $detailNameCell = if ($detailPortalUrl) {
            "<a href=`"$detailPortalUrl`" class=`"portal-link`" target=`"_blank`" title=`"Open in Azure Portal`"><strong>$encDetailName</strong></a>"
        } else { "<strong>$encDetailName</strong>" }

        [void]$clusterIdentityHtml.Append(@"
 
                <tr>
                    <td>$detailNameCell</td>
                    <td>$encDetailVersion</td>
                    <td>$encDetailSbe</td>
                    <td>$encDetailNodes</td>
                    <td>$encDetailRG</td>
                    <td class="resource-id-cell">$encDetailRID</td>
                </tr>
"@
)
    }
    [void]$clusterIdentityHtml.Append(@"
 
            </tbody>
        </table>
    </div>
"@
)
    $clusterIdentitySection = $clusterIdentityHtml.ToString()

    #--- Build HTML ---
    Write-Log -Message "Generating HTML report..." -Level Info

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.Append(@"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>$([System.Web.HttpUtility]::HtmlEncode($Title))</title>
    <style>
        :root {
            --success-color: #28a745;
            --failure-color: #dc3545;
            --warning-color: #ffc107;
            --info-color: #17a2b8;
            --pending-color: #6c757d;
            --bg-color: #f8f9fa;
            --card-bg: #ffffff;
            --text-color: #212529;
            --border-color: #dee2e6;
        }
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background-color: var(--bg-color);
            color: var(--text-color);
            line-height: 1.6;
            padding: 20px;
        }
        .container { max-width: 1400px; margin: 0 auto; }
        header {
            background: linear-gradient(135deg, #552F99, #B596F5);
            color: white;
            padding: 30px;
            border-radius: 10px;
            margin-bottom: 20px;
            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
            position: relative;
        }
        header h1 { font-size: 2em; margin-bottom: 10px; }
        header p { opacity: 0.9; }
        header .logo {
            position: absolute;
            top: 20px;
            right: 30px;
            width: 64px;
            height: 64px;
            opacity: 0.9;
        }
        .summary {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
            gap: 15px;
            margin-bottom: 20px;
        }
        .summary-card {
            background: var(--card-bg);
            padding: 20px;
            border-radius: 8px;
            text-align: center;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            border-left: 4px solid var(--border-color);
        }
        .summary-card.total { border-left-color: #9266E6; }
        .summary-card.uptodate { border-left-color: var(--success-color); }
        .summary-card.inprogress { border-left-color: var(--warning-color); }
        .summary-card.available { border-left-color: var(--info-color); }
        .summary-card.failures { border-left-color: var(--failure-color); }
        .summary-card .number {
            font-size: 2.5em; font-weight: bold; display: block;
        }
        .summary-card.total .number { color: #9266E6; }
        .summary-card.uptodate .number { color: var(--success-color); }
        .summary-card.inprogress .number { color: var(--warning-color); }
        .summary-card.available .number { color: var(--info-color); }
        .summary-card.failures .number { color: var(--failure-color); }
        .summary-card .label {
            text-transform: uppercase; font-size: 0.85em;
            color: #6c757d; letter-spacing: 1px;
        }
        .progress-bar-container {
            background: var(--card-bg);
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .progress-bar-container h3 { margin-bottom: 10px; }
        .progress {
            height: 30px; background: #e9ecef;
            border-radius: 15px; overflow: hidden; display: flex;
        }
        .progress-uptodate { background: var(--success-color); }
        .progress-inprogress { background: var(--warning-color); }
        .progress-available { background: var(--info-color); }
        .progress-failures { background: var(--failure-color); }
        .progress-other { background: var(--pending-color); }
        .section {
            background: var(--card-bg);
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            overflow-x: auto;
            margin-bottom: 20px;
        }
        .section h2 {
            background: #f1f3f5;
            padding: 15px 20px;
            border-bottom: 1px solid var(--border-color);
        }
        table {
            width: 100%; border-collapse: collapse;
            min-width: 800px;
        }
        th {
            background: #f8f9fa; padding: 12px 16px;
            text-align: left; font-weight: 600;
            border-bottom: 2px solid var(--border-color);
            white-space: nowrap;
        }
        td {
            padding: 12px 16px;
            border-bottom: 1px solid #f1f3f5;
        }
        tr:hover td { background: #f8f9fa; }
        .status-badge {
            display: inline-block; padding: 4px 12px;
            border-radius: 12px; font-size: 0.85em;
            font-weight: 600; white-space: nowrap;
        }
        .status-uptodate { background: #d4edda; color: #155724; }
        .status-inprogress { background: #fff3cd; color: #856404; }
        .status-available { background: #d1ecf1; color: #0c5460; }
        .status-failure { background: #f8d7da; color: #721c24; }
        .status-unknown { background: #e2e3e5; color: #383d41; }
        .severity-critical { background: #f8d7da; color: #721c24; font-weight: 600; }
        .severity-warning { background: #fff3cd; color: #856404; }
        .severity-info { background: #d1ecf1; color: #0c5460; }
        .message-cell {
            max-width: 500px;
            white-space: normal;
            word-wrap: break-word;
            font-size: 0.9em;
        }
        .resource-id-cell {
            font-family: 'Consolas', 'Courier New', monospace;
            font-size: 0.85em;
            white-space: nowrap;
            user-select: all;
        }
        a.portal-link {
            color: #0078d4;
            text-decoration: none;
            border-bottom: 1px dashed #0078d4;
        }
        a.portal-link:hover {
            color: #005a9e;
            border-bottom-color: #005a9e;
        }
        details {
            margin-bottom: 8px;
        }
        details summary {
            cursor: pointer;
            padding: 10px 16px;
            border-bottom: 1px solid #f1f3f5;
            font-weight: 600;
            list-style: none;
        }
        details summary::-webkit-details-marker { display: none; }
        details summary::before {
            content: '\25B6';
            display: inline-block;
            margin-right: 8px;
            transition: transform 0.2s;
            font-size: 0.8em;
        }
        details[open] summary::before {
            transform: rotate(90deg);
        }
        details[open] summary {
            border-bottom: 2px solid var(--border-color);
        }
        .failure-summary-counts {
            font-weight: normal;
            font-size: 0.9em;
            color: #6c757d;
            margin-left: 8px;
        }
        .failure-summary-top-issue {
            font-weight: normal;
            font-size: 0.85em;
            color: #495057;
            margin-left: 4px;
        }
        footer {
            text-align: center; padding: 20px;
            color: #6c757d; font-size: 0.9em;
        }
        .severity-filter {
            padding: 10px 20px;
            background: #f8f9fa;
            border-bottom: 1px solid var(--border-color);
            font-size: 0.9em;
        }
        .severity-filter label {
            margin-right: 16px;
            cursor: pointer;
            user-select: none;
        }
        .severity-filter input[type="checkbox"] {
            margin-right: 4px;
            cursor: pointer;
        }
        tr.sev-hidden { display: none; }
    </style>
    <script>
    function toggleSeverity(severity, checked) {
        var rows = document.querySelectorAll('tr.sev-' + severity);
        for (var i = 0; i < rows.length; i++) {
            if (checked) { rows[i].classList.remove('sev-hidden'); }
            else { rows[i].classList.add('sev-hidden'); }
        }
    }
    </script>
</head>
<body>
<div class="container">
    <header>
        <svg class="logo" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"><path d="M125.25 107.513c-1.13 4.978-7.317 9.827-18.488 13.625a152.571 152.571 0 0 1-85.704.121c-10.303-3.648-15.864-8.263-16.732-13.021-.15-.839 0-13.895 0-13.895l121.159-1.123s-.092 13.681-.235 14.293Z" fill="#5EA0EF"/><path d="M65.04 115.031c33.479-.336 60.525-9.991 60.409-21.564-.116-11.573-27.35-20.683-60.83-20.347-33.479.336-60.525 9.99-60.409 21.564.116 11.573 27.35 20.683 60.83 20.347Z" fill="#50E6FF"/><path d="M105.989 11H22.011A3.011 3.011 0 0 0 19 14.011v18.585a3.011 3.011 0 0 0 3.011 3.011h83.978a3.011 3.011 0 0 0 3.011-3.01V14.01a3.011 3.011 0 0 0-3.011-3.01Z" fill="#B596F5"/><path d="M100.23 15.307h-3.72c-.83 0-1.504.673-1.504 1.503v3.72c0 .83.673 1.503 1.503 1.503h3.721c.83 0 1.502-.673 1.502-1.503v-3.72c0-.83-.672-1.503-1.502-1.503Zm0 9.273h-3.72c-.83 0-1.504.673-1.504 1.503v3.72c0 .83.673 1.503 1.503 1.503h3.721c.83 0 1.502-.673 1.502-1.503v-3.72c0-.83-.672-1.503-1.502-1.503Z" fill="#F2F2F2"/><path d="M105.989 40.166H22.011A3.011 3.011 0 0 0 19 43.176v18.586a3.011 3.011 0 0 0 3.011 3.011h83.978a3.011 3.011 0 0 0 3.011-3.01V43.176a3.01 3.01 0 0 0-3.011-3.011Z" fill="#9266E6"/><path d="M100.23 44.467h-3.72c-.83 0-1.504.673-1.504 1.503v3.72c0 .83.673 1.503 1.503 1.503h3.721c.83 0 1.502-.673 1.502-1.503v-3.72c0-.83-.672-1.503-1.502-1.503Zm0 9.273h-3.72c-.83 0-1.504.673-1.504 1.503v3.72c0 .83.673 1.503 1.503 1.503h3.721c.83 0 1.502-.673 1.502-1.502v-3.72c0-.83-.672-1.504-1.502-1.504Z" fill="#F2F2F2"/><path d="M105.989 69.326H22.011A3.011 3.011 0 0 0 19 72.336v18.586a3.011 3.011 0 0 0 3.011 3.011h83.978a3.01 3.01 0 0 0 3.011-3.01V72.336a3.011 3.011 0 0 0-3.011-3.011Z" fill="#552F99"/><path d="M100.23 73.627h-3.72c-.83 0-1.504.673-1.504 1.503v3.72c0 .83.673 1.503 1.503 1.503h3.721c.83 0 1.502-.673 1.502-1.502V75.13c0-.83-.672-1.503-1.502-1.503Zm0 9.273h-3.72c-.83 0-1.504.673-1.504 1.503v3.72c0 .83.673 1.503 1.503 1.503h3.721c.83 0 1.502-.672 1.502-1.502v-3.72c0-.83-.672-1.504-1.502-1.504Z" fill="#F2F2F2"/></svg>
        <h1>$([System.Web.HttpUtility]::HtmlEncode($Title))</h1>
        <p>Generated $([System.Web.HttpUtility]::HtmlEncode($timestamp)) | Scope: $([System.Web.HttpUtility]::HtmlEncode($scopeDescription))</p>
    </header>
 
    <div class="summary">
        <div class="summary-card total">
            <span class="number">$([System.Web.HttpUtility]::HtmlEncode($totalClusters))</span>
            <span class="label">Total Clusters</span>
        </div>
        <div class="summary-card uptodate">
            <span class="number">$([System.Web.HttpUtility]::HtmlEncode($upToDate))</span>
            <span class="label">Up to Date</span>
        </div>
        <div class="summary-card inprogress">
            <span class="number">$([System.Web.HttpUtility]::HtmlEncode($inProgress))</span>
            <span class="label">In Progress</span>
        </div>
        <div class="summary-card available">
            <span class="number">$([System.Web.HttpUtility]::HtmlEncode($updateAvailable))</span>
            <span class="label">Ready for Update</span>
        </div>
        <div class="summary-card failures">
            <span class="number">$([System.Web.HttpUtility]::HtmlEncode($healthFailures))</span>
            <span class="label">Health Failures</span>
        </div>
    </div>
 
    <div class="progress-bar-container">
        <h3>Fleet Update Progress</h3>
        <div class="progress">
            <div class="progress-uptodate" style="width: $pctUpToDate%;" title="Up to Date: $upToDate ($pctUpToDate%)"></div>
            <div class="progress-inprogress" style="width: $pctInProgress%;" title="In Progress: $inProgress ($pctInProgress%)"></div>
            <div class="progress-available" style="width: $pctAvailable%;" title="Ready for Update: $updateAvailable ($pctAvailable%)"></div>
            <div class="progress-failures" style="width: $pctFailures%;" title="Health Failures: $healthFailures ($pctFailures%)"></div>
            <div class="progress-other" style="width: $pctOther%;" title="Other: $otherCount ($pctOther%)"></div>
        </div>
    </div>
"@
)

    # Insert cluster identity section at top for 10 or fewer clusters
    if ($clusterDetails.Count -le 10) {
        [void]$sb.Append($clusterIdentitySection)
    }

    [void]$sb.Append(@"
 
    <div class="section">
        <h2>Cluster Status Details</h2>
        <table>
            <thead>
                <tr>
                    <th>Cluster Name</th>
                    <th>Resource Group</th>
                    <th>Update State</th>
                    <th>Health State</th>
                    <th>Ready</th>
                    <th>Active Update</th>
                    <th>Recommended Update</th>
                </tr>
            </thead>
            <tbody>
"@
)

    # Pre-build hash indexes for O(1) lookups inside the per-cluster/per-run loops below.
    # Replaces repeated O(N) "Where-Object ClusterName -eq $x" scans that caused
    # quadratic time on large fleets (e.g. 500 clusters x 500 runs).
    $latestRunsByCluster = @{}
    foreach ($__r in $latestRuns) {
        if ($__r -and $__r.ClusterName -and -not $latestRunsByCluster.ContainsKey($__r.ClusterName)) {
            $latestRunsByCluster[$__r.ClusterName] = $__r
        }
    }
    $clusterDetailsByName = @{}
    foreach ($__d in $clusterDetails) {
        if ($__d -and $__d.ClusterName -and -not $clusterDetailsByName.ContainsKey($__d.ClusterName)) {
            $clusterDetailsByName[$__d.ClusterName] = $__d
        }
    }

    foreach ($cluster in $readiness) {
        $updateBadge = switch ($cluster.UpdateState) {
            'UpToDate'              { 'status-uptodate' }
            'AppliedSuccessfully'   { 'status-uptodate' }
            'UpdateInProgress'      { 'status-inprogress' }
            'UpdateFailed'          { 'status-failure' }
            'UpdateAvailable'       { 'status-available' }
            'Ready'                 { 'status-available' }
            default                 { 'status-unknown' }
        }
        $healthBadge = switch ($cluster.HealthState) {
            'Success' { 'status-uptodate' }
            'Failure' { 'status-failure' }
            'Warning' { 'status-inprogress' }
            default   { 'status-unknown' }
        }
        $readyText = if ($cluster.ReadyForUpdate) { "Yes" } else { "No" }
        $encReadyText = [System.Web.HttpUtility]::HtmlEncode($readyText)

        # Determine active update (in-progress or failed) from latest run data
        $activeUpdate = ""
        $activeUpdateBadge = ""
        $activeUpdateName = ""
        $recommendedDisplay = $cluster.RecommendedUpdate
        $clusterLatestRun = if ($cluster.ClusterName -and $latestRunsByCluster.ContainsKey($cluster.ClusterName)) { $latestRunsByCluster[$cluster.ClusterName] } else { $null }
        if ($clusterLatestRun -and $clusterLatestRun.State -in @("InProgress", "Failed")) {
            $activeUpdate = "$($clusterLatestRun.UpdateName) ($($clusterLatestRun.State))"
            $activeUpdateName = $clusterLatestRun.UpdateName
            $activeUpdateBadge = if ($clusterLatestRun.State -eq "InProgress") { "status-inprogress" } else { "status-failure" }
            # Show N/A for recommended when there's an active update that must be completed
            $recommendedDisplay = "N/A"
        }

        # Build portal URLs
        $clusterResourceId = if ($cluster.ClusterName -and $clusterDetailsByName.ContainsKey($cluster.ClusterName)) { $clusterDetailsByName[$cluster.ClusterName].ResourceId } else { $null }
        $clusterPortalUrl = if ($clusterResourceId) { [System.Web.HttpUtility]::HtmlEncode("https://portal.azure.com/#@/resource$clusterResourceId") } else { "" }
        $updatePortalUrl = if ($clusterResourceId -and $activeUpdateName) { [System.Web.HttpUtility]::HtmlEncode("https://portal.azure.com/#@/resource$clusterResourceId/updates") } else { "" }

        $encName    = [System.Web.HttpUtility]::HtmlEncode($cluster.ClusterName)
        $encRG      = [System.Web.HttpUtility]::HtmlEncode($cluster.ResourceGroup)
        $encUpdate  = [System.Web.HttpUtility]::HtmlEncode($cluster.UpdateState)
        $encHealth  = [System.Web.HttpUtility]::HtmlEncode($cluster.HealthState)
        $encActive  = [System.Web.HttpUtility]::HtmlEncode($activeUpdate)
        $encRecommended = [System.Web.HttpUtility]::HtmlEncode($recommendedDisplay)

        # Cluster name as portal link
        $nameCell = if ($clusterPortalUrl) {
            "<a href=`"$clusterPortalUrl`" class=`"portal-link`" target=`"_blank`" title=`"Open in Azure Portal`"><strong>$encName</strong></a>"
        } else { "<strong>$encName</strong>" }

        # Active update as portal link
        $activeCell = if ($activeUpdate -and $updatePortalUrl) {
            "<a href=`"$updatePortalUrl`" class=`"portal-link`" target=`"_blank`" title=`"View updates in Azure Portal`"><span class=`"status-badge $activeUpdateBadge`">$encActive</span></a>"
        } elseif ($activeUpdate) {
            "<span class=`"status-badge $activeUpdateBadge`">$encActive</span>"
        } else { "" }

        [void]$sb.Append(@"
 
                <tr>
                    <td>$nameCell</td>
                    <td>$encRG</td>
                    <td><span class="status-badge $updateBadge">$encUpdate</span></td>
                    <td><span class="status-badge $healthBadge">$encHealth</span></td>
                    <td>$encReadyText</td>
                    <td>$activeCell</td>
                    <td>$encRecommended</td>
                </tr>
"@
)
    }

    [void]$sb.Append(@"
 
            </tbody>
        </table>
    </div>
"@
)

    #--- Update Run History section (optional) ---
    if ($IncludeUpdateRuns -and $updateRuns.Count -gt 0) {
        # Only show the Attempts column if at least one update had multiple attempts,
        # so the common case (all successes on first try) stays visually clean.
        $showAttempts = @($updateRuns | Where-Object { $_.PSObject.Properties['Attempts'] -and [int]$_.Attempts -gt 1 }).Count -gt 0
        [void]$sb.Append(@"
 
    <div class="section">
        <h2>Recent Update Run History</h2>
        <table>
            <thead>
                <tr>
                    <th>Cluster</th>
                    <th>Update Name</th>
                    <th>State</th>
                    <th>Progress</th>
                    <th>Current Step</th>
$(if ($showAttempts) { " <th>Update Attempts</th>`n" }) <th>Duration</th>
                    <th>Start Time</th>
                    <th>End Time</th>
                </tr>
            </thead>
            <tbody>
"@
)
        foreach ($run in $updateRuns) {
            $runBadge = switch ($run.State) {
                'Succeeded'  { 'status-uptodate' }
                'Failed'     { 'status-failure' }
                'InProgress' { 'status-inprogress' }
                default      { 'status-unknown' }
            }
            # Build portal links for cluster and update (uses $clusterDetailsByName pre-built above)
            $runClusterRid = if ($run.ClusterName -and $clusterDetailsByName.ContainsKey($run.ClusterName)) { $clusterDetailsByName[$run.ClusterName].ResourceId } else { $null }
            $runClusterUrl = if ($runClusterRid) { [System.Web.HttpUtility]::HtmlEncode("https://portal.azure.com/#@/resource$runClusterRid") } else { "" }
            $runUpdateUrl  = if ($runClusterRid) { [System.Web.HttpUtility]::HtmlEncode("https://portal.azure.com/#@/resource$runClusterRid/updates") } else { "" }

            $encRunCluster  = [System.Web.HttpUtility]::HtmlEncode($run.ClusterName)
            $encRunUpdate   = [System.Web.HttpUtility]::HtmlEncode($run.UpdateName)
            $encRunState    = [System.Web.HttpUtility]::HtmlEncode($run.State)
            $encRunProgress = [System.Web.HttpUtility]::HtmlEncode($run.Progress)
            $encRunStep     = [System.Web.HttpUtility]::HtmlEncode($run.CurrentStepDetail)
            $encRunDuration = [System.Web.HttpUtility]::HtmlEncode($run.Duration)
            $encRunStart    = [System.Web.HttpUtility]::HtmlEncode($run.StartTime)
            $encRunEnd      = if ($run.PSObject.Properties['EndTime']) { [System.Web.HttpUtility]::HtmlEncode($run.EndTime) } else { '' }
            $runAttempts    = if ($run.PSObject.Properties['Attempts'] -and $run.Attempts) { [int]$run.Attempts } else { 1 }
            $encRunAttempts = [System.Web.HttpUtility]::HtmlEncode([string]$runAttempts)

            $runClusterCell = if ($runClusterUrl) {
                "<a href=`"$runClusterUrl`" class=`"portal-link`" target=`"_blank`"><strong>$encRunCluster</strong></a>"
            } else { "<strong>$encRunCluster</strong>" }

            $runUpdateCell = if ($runUpdateUrl) {
                "<a href=`"$runUpdateUrl`" class=`"portal-link`" target=`"_blank`" title=`"View update history in Azure Portal`">$encRunUpdate</a>"
            } else { $encRunUpdate }

            [void]$sb.Append(@"
 
                <tr>
                    <td>$runClusterCell</td>
                    <td>$runUpdateCell</td>
                    <td><span class="status-badge $runBadge">$encRunState</span></td>
                    <td>$encRunProgress</td>
                    <td class="message-cell" title="$encRunStep">$encRunStep</td>
$(if ($showAttempts) { " <td>$encRunAttempts</td>`n" }) <td>$encRunDuration</td>
                    <td>$encRunStart</td>
                    <td>$encRunEnd</td>
                </tr>
"@
)
        }

        [void]$sb.Append(@"
 
            </tbody>
        </table>
    </div>
"@
)
    }

    #--- Health Check Failures section (optional) ---
    if ($IncludeHealthDetails -and $healthResults.Count -gt 0) {
        $allFailures = @($healthResults | ForEach-Object { $_.Failures } | Where-Object { $_ })
        if ($allFailures.Count -gt 0) {
            $uniqueFailureClusters = @($allFailures | Select-Object -ExpandProperty ClusterName -Unique)

            # Pre-group failures by ClusterName for O(1) lookups in the per-cluster loop below.
            $failuresByCluster = @{}
            foreach ($__f in $allFailures) {
                if (-not $__f -or -not $__f.ClusterName) { continue }
                if (-not $failuresByCluster.ContainsKey($__f.ClusterName)) {
                    $failuresByCluster[$__f.ClusterName] = [System.Collections.Generic.List[object]]::new()
                }
                [void]$failuresByCluster[$__f.ClusterName].Add($__f)
            }

            if ($uniqueFailureClusters.Count -le 1) {
                # Single cluster: flat table (no collapsing)
                [void]$sb.Append(@"
 
    <div class="section">
        <h2>Health Check Failures</h2>
        <div class="severity-filter">
            Filter by Severity:
            <label><input type="checkbox" checked onchange="toggleSeverity('critical', this.checked)"> Critical</label>
            <label><input type="checkbox" checked onchange="toggleSeverity('warning', this.checked)"> Warning</label>
            <label><input type="checkbox" onchange="toggleSeverity('informational', this.checked)"> Informational</label>
        </div>
        <table>
            <thead>
                <tr>
                    <th>Cluster</th>
                    <th>Severity</th>
                    <th>Check Name</th>
                    <th>Target</th>
                    <th>Description</th>
                    <th>Remediation</th>
                </tr>
            </thead>
            <tbody>
"@
)
                foreach ($failure in $allFailures) {
                    $sevBadge = switch ($failure.Severity) {
                        'Critical'      { 'severity-critical' }
                        'Warning'       { 'severity-warning' }
                        'Informational' { 'severity-info' }
                        default         { 'status-unknown' }
                    }
                    $sevClass = "sev-$($failure.Severity.ToLower())"
                    $sevHidden = if ($failure.Severity -eq 'Informational') { ' sev-hidden' } else { '' }
                    $encCluster = [System.Web.HttpUtility]::HtmlEncode($failure.ClusterName)
                    $encSev     = [System.Web.HttpUtility]::HtmlEncode($failure.Severity)
                    $encCheck   = [System.Web.HttpUtility]::HtmlEncode($failure.CheckName)
                    $encTarget  = [System.Web.HttpUtility]::HtmlEncode($failure.TargetResourceName)
                    $encDesc    = [System.Web.HttpUtility]::HtmlEncode($failure.Description)
                    $encRemed   = [System.Web.HttpUtility]::HtmlEncode($failure.Remediation)

                    [void]$sb.Append(@"
 
                <tr class="$sevClass$sevHidden">
                    <td><strong>$encCluster</strong></td>
                    <td><span class="status-badge $sevBadge">$encSev</span></td>
                    <td>$encCheck</td>
                    <td>$encTarget</td>
                    <td class="message-cell" title="$encDesc">$encDesc</td>
                    <td class="message-cell" title="$encRemed">$encRemed</td>
                </tr>
"@
)
                }

                [void]$sb.Append(@"
 
            </tbody>
        </table>
    </div>
"@
)
            }
            else {
                # Multiple clusters: collapsible per-cluster groups
                [void]$sb.Append(@"
 
    <div class="section">
        <h2>Health Check Failures</h2>
        <div class="severity-filter">
            Filter by Severity:
            <label><input type="checkbox" checked onchange="toggleSeverity('critical', this.checked)"> Critical</label>
            <label><input type="checkbox" checked onchange="toggleSeverity('warning', this.checked)"> Warning</label>
            <label><input type="checkbox" onchange="toggleSeverity('informational', this.checked)"> Informational</label>
        </div>
"@
)
                foreach ($clusterGroup in $uniqueFailureClusters) {
                    $clusterFailures = if ($failuresByCluster.ContainsKey($clusterGroup)) { @($failuresByCluster[$clusterGroup]) } else { @() }
                    $critCount = @($clusterFailures | Where-Object { $_.Severity -eq 'Critical' }).Count
                    $warnCount = @($clusterFailures | Where-Object { $_.Severity -eq 'Warning' }).Count
                    $infoCount = @($clusterFailures | Where-Object { $_.Severity -eq 'Informational' }).Count

                    # Determine worst severity badge and top issue
                    $worstBadge = if ($critCount -gt 0) { 'severity-critical' } elseif ($warnCount -gt 0) { 'severity-warning' } else { 'severity-info' }
                    $worstLabel = if ($critCount -gt 0) { 'Critical' } elseif ($warnCount -gt 0) { 'Warning' } else { 'Informational' }
                    $topIssue = ($clusterFailures | Sort-Object { switch ($_.Severity) { 'Critical' { 0 } 'Warning' { 1 } default { 2 } } } | Select-Object -First 1).CheckName

                    # Build count summary
                    $countParts = @()
                    if ($critCount -gt 0) { $countParts += "$critCount Critical" }
                    if ($warnCount -gt 0) { $countParts += "$warnCount Warning" }
                    if ($infoCount -gt 0) { $countParts += "$infoCount Informational" }
                    $countSummary = $countParts -join ', '

                    $encGroupName = [System.Web.HttpUtility]::HtmlEncode($clusterGroup)
                    $encTopIssue  = [System.Web.HttpUtility]::HtmlEncode($topIssue)

                    [void]$sb.Append(@"
 
        <details>
            <summary>
                <strong>$encGroupName</strong>
                <span class="status-badge $worstBadge">$([System.Web.HttpUtility]::HtmlEncode($worstLabel))</span>
                <span class="failure-summary-counts">$([System.Web.HttpUtility]::HtmlEncode($countSummary))</span>
                <span class="failure-summary-top-issue">| $encTopIssue</span>
            </summary>
            <table>
                <thead>
                    <tr>
                        <th>Severity</th>
                        <th>Check Name</th>
                        <th>Target</th>
                        <th>Description</th>
                        <th>Remediation</th>
                    </tr>
                </thead>
                <tbody>
"@
)
                    foreach ($failure in $clusterFailures) {
                        $sevBadge = switch ($failure.Severity) {
                            'Critical'      { 'severity-critical' }
                            'Warning'       { 'severity-warning' }
                            'Informational' { 'severity-info' }
                            default         { 'status-unknown' }
                        }
                        $sevClass = "sev-$($failure.Severity.ToLower())"
                        $sevHidden = if ($failure.Severity -eq 'Informational') { ' sev-hidden' } else { '' }
                        $encSev    = [System.Web.HttpUtility]::HtmlEncode($failure.Severity)
                        $encCheck  = [System.Web.HttpUtility]::HtmlEncode($failure.CheckName)
                        $encTarget = [System.Web.HttpUtility]::HtmlEncode($failure.TargetResourceName)
                        $encDesc   = [System.Web.HttpUtility]::HtmlEncode($failure.Description)
                        $encRemed  = [System.Web.HttpUtility]::HtmlEncode($failure.Remediation)

                        [void]$sb.Append(@"
 
                    <tr class="$sevClass$sevHidden">
                        <td><span class="status-badge $sevBadge">$encSev</span></td>
                        <td>$encCheck</td>
                        <td>$encTarget</td>
                        <td class="message-cell" title="$encDesc">$encDesc</td>
                        <td class="message-cell" title="$encRemed">$encRemed</td>
                    </tr>
"@
)
                    }

                    [void]$sb.Append(@"
 
                </tbody>
            </table>
        </details>
"@
)
                }

                [void]$sb.Append(@"
 
    </div>
"@
)
            }
        }
    }

    #--- Cluster identity appendix for large fleets (>10 clusters) ---
    if ($clusterDetails.Count -gt 10) {
        [void]$sb.Append($clusterIdentitySection)
    }

    #--- Footer ---
    $moduleVersion = (Get-Module AzLocal.UpdateManagement | Select-Object -First 1).Version
    if (-not $moduleVersion) {
        $manifestPath = Join-Path -Path $PSScriptRoot -ChildPath 'AzLocal.UpdateManagement.psd1'
        if (Test-Path $manifestPath) {
            $manifest = Import-PowerShellDataFile -Path $manifestPath -ErrorAction SilentlyContinue
            $moduleVersion = $manifest.ModuleVersion
        }
        if (-not $moduleVersion) { $moduleVersion = "unknown" }
    }

    [void]$sb.Append(@"
 
    <footer>
        <p>Generated by AzLocal.UpdateManagement v$moduleVersion | $timestamp</p>
        <p>This report is provided as-is with no warranty. Not a Microsoft supported service offering.</p>
    </footer>
</div>
</body>
</html>
"@
)

    $htmlContent = $sb.ToString()

    #--- Write to file ---
    if (-not $PSCmdlet.ShouldProcess($OutputPath, 'Write HTML fleet status report')) {
        return $htmlContent
    }
    $OutputPath = Resolve-SafeOutputPath -Path $OutputPath
    $outputDir = Split-Path -Path $OutputPath -Parent
    if ($outputDir -and -not (Test-Path $outputDir)) {
        New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
    }
    # Write UTF-8 *without* BOM. PowerShell 5.1's Out-File -Encoding UTF8
    # emits a BOM which breaks some browsers' rendering of the first bytes
    # and confuses downstream tooling (grep/diff/CI log viewers).
    Write-Utf8NoBomFile -Path $OutputPath -Content $htmlContent

    Write-Log -Message "" -Level Info
    Write-Log -Message "HTML fleet status report written to: $OutputPath" -Level Success
    try {
        $fullPath = (Resolve-Path $OutputPath -ErrorAction Stop).Path
        $fileUri = "file:///$($fullPath -replace '\\', '/')"
        Write-Log -Message " Open report: $fileUri" -Level Info
    }
    catch {
        Write-Log -Message " Open report: $OutputPath" -Level Info
    }
    Write-Log -Message " Total Clusters: $totalClusters | Up to Date: $upToDate | In Progress: $inProgress | Ready: $updateAvailable | Failures: $healthFailures" -Level Info

    if ($PassThru) {
        return $htmlContent
    }
}