Public/Get-AzLocalUpdateRuns.ps1

function Get-AzLocalUpdateRuns {
    <#
    .SYNOPSIS
        Gets update run history and status for one or more Azure Local clusters.
    .DESCRIPTION
        Retrieves update run information for Azure Local (Azure Stack HCI) clusters.
        Update runs contain the history and status of update operations including
        start time, end time, progress, and any errors that occurred.
 
        Supports multiple input methods:
        - Single cluster by name (uses ARM REST against the cluster's /updateRuns endpoint)
        - Multiple clusters by name or resource ID (single ARG query)
        - All clusters matching an UpdateRing tag value (single ARG query)
 
        In multi-cluster mode (v0.7.68+) all update runs are returned by ONE
        Azure Resource Graph query against the `extensibilityresources`
        namespace (microsoft.azurestackhci/clusters/updates/updateruns) -
        typically completes in under 10 seconds for hundreds of clusters,
        replacing the previous per-cluster ARM REST fan-out which took
        minutes for moderately-sized fleets.
 
        Returns clean, human-readable objects with key information extracted from the API response.
    .PARAMETER ClusterName
        The name of a single Azure Local cluster (original behavior).
    .PARAMETER ClusterNames
        An array of Azure Local cluster names to query. In v0.7.68+ these are
        resolved cross-subscription via a single ARG batch lookup (no per-name
        ARM REST calls).
    .PARAMETER ClusterResourceIds
        An array of full Azure Resource IDs for the clusters to query.
    .PARAMETER ScopeByUpdateRingTag
        When specified, finds clusters by the 'UpdateRing' tag via Azure Resource Graph.
        Must be used together with -UpdateRingValue.
    .PARAMETER UpdateRingValue
        The value of the 'UpdateRing' tag to match when using -ScopeByUpdateRingTag.
    .PARAMETER ResourceGroupName
        The resource group containing the cluster. If not specified, searches all resource groups.
    .PARAMETER SubscriptionId
        Optional. The Azure subscription ID to scope queries to. When omitted,
        the multi-cluster mode queries every subscription the caller can read
        via Azure Resource Graph (cross-subscription default since v0.7.68).
    .PARAMETER UpdateName
        Optional. The specific update name to get runs for. If not specified, returns runs for all updates.
    .PARAMETER Latest
        Optional. Return only the most recent update run per cluster.
    .PARAMETER Raw
        Optional. Return the raw API response objects instead of formatted output.
        Only applies to the single-cluster mode.
    .PARAMETER ApiVersion
        The Azure REST API version to use. Default is the module's default API version.
        Only used by the single-cluster mode; the multi-cluster mode uses ARG.
    .PARAMETER ExportPath
        Path to export the results. Supports .csv, .json, and .xml (JUnit format) extensions.
    .OUTPUTS
        PSCustomObject[] - Array of update run objects with the following properties:
        - ClusterName: The cluster name (in multi-cluster mode)
        - UpdateName: The update package name (e.g., "Solution12.2601.1002.38")
        - RunId: The unique GUID for this update run
        - State: Current state (InProgress, Succeeded, Failed, etc.)
        - StartTime: When the update run started
        - Duration: How long the update has been running or took to complete
        - Progress: Step completion progress (e.g., "3/5 steps")
        - CurrentStep: The currently executing or failed step name
        - Location: Azure region
    .EXAMPLE
        # Single cluster (original behavior)
        Get-AzLocalUpdateRuns -ClusterName "MyCluster" -ResourceGroupName "MyRG"
    .EXAMPLE
        # Multiple clusters by tag
        Get-AzLocalUpdateRuns -ScopeByUpdateRingTag -UpdateRingValue "Wave1" -Latest
    .EXAMPLE
        # Export to CSV
        Get-AzLocalUpdateRuns -ScopeByUpdateRingTag -UpdateRingValue "Production" -Latest -ExportPath "C:\Reports\runs.csv"
    .EXAMPLE
        Get-AzLocalUpdateRuns -ClusterName "MyCluster" -Raw
        Gets raw API response for programmatic processing.
    #>

    [CmdletBinding(DefaultParameterSetName = 'SingleCluster')]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'SingleCluster')]
        [string]$ClusterName,

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

        [Parameter(Mandatory = $true, ParameterSetName = 'ByResourceId')]
        [string[]]$ClusterResourceIds,

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

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

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

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

        [Parameter(Mandatory = $false)]
        [string]$UpdateName,

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

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

        [Parameter(Mandatory = $false)]
        [string]$ApiVersion = $script:DefaultApiVersion,

        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByResourceId')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByTag')]
        [string]$ExportPath,

        [Parameter(Mandatory = $false, ParameterSetName = 'ByName')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByResourceId')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ByTag')]
        [ValidateSet('Auto', 'Csv', 'Json', 'JUnitXml')]
        [string]$ExportFormat = 'Auto',

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

        # v0.7.1: when omitted (default), Get-AzLocalUpdateRuns will auto-reset
        # the UpdateSideloaded tag (True->False) and clear UpdateVersionInProgress
        # for any cluster whose latest update run is Succeeded AND whose
        # UpdateVersionInProgress tag matches the run's update name. Pass this
        # switch on read-only audit pipelines that must not mutate cluster tags.
        [Parameter(Mandatory = $false)]
        [switch]$SkipSideloadedReset
    )

    # Pre-flight: Validate export path is writable before expensive operations
    if ($ExportPath) {
        try { Test-ExportPathWritable -Path $ExportPath | Out-Null }
        catch { Write-Warning $_.Exception.Message; return }
    }

    # Original single-cluster behavior
    if ($PSCmdlet.ParameterSetName -eq 'SingleCluster') {
        Test-AzCliAvailable | Out-Null
        Write-Log -Message "" -Level Info
        Write-Log -Message "========================================" -Level Header
        Write-Log -Message "Azure Local Cluster Update Runs" -Level Header
        Write-Log -Message "========================================" -Level Header
        Write-Log -Message "Cluster: $ClusterName" -Level Info

        if (-not $SubscriptionId) {
            $SubscriptionId = (az account show --query id -o tsv)
            Write-Log -Message "Using current subscription: $SubscriptionId" -Level Info
        }

        Write-Log -Message "Looking up cluster resource..." -Level Info
        $clusterInfo = Get-AzLocalClusterInfo -ClusterName $ClusterName `
            -ResourceGroupName $ResourceGroupName `
            -SubscriptionId $SubscriptionId `
            -ApiVersion $ApiVersion

        if (-not $clusterInfo) {
            Write-Log -Message "Cluster '$ClusterName' not found." -Level Error
            return $null
        }
        Write-Log -Message "Found cluster: $($clusterInfo.id)" -Level Success

        Write-Log -Message "Querying update runs..." -Level Info
        $allRuns = Get-AzLocalClusterUpdateRuns -resourceId $clusterInfo.id -updateNameFilter $UpdateName -apiVer $ApiVersion
        Write-Log -Message "Found $($allRuns.Count) update run(s)" -Level $(if ($allRuns.Count -gt 0) { "Success" } else { "Warning" })

        if ($Raw) {
            if ($Latest) {
                return $allRuns | Sort-Object { $_.properties.timeStarted } -Descending | Select-Object -First 1
            }
            return $allRuns
        }

        # Format runs
        $formattedRuns = [System.Collections.Generic.List[object]]::new()
        foreach ($run in $allRuns) {
            $formattedRuns.Add((Format-AzLocalUpdateRun -run $run -clusterName $ClusterName -clusterResourceId $clusterInfo.id)) | Out-Null
        }

        $formattedRuns = @($formattedRuns | Sort-Object StartTime -Descending)

        if ($Latest) {
            $formattedRuns = @($formattedRuns | Select-Object -First 1)
        }

        if ($formattedRuns.Count -gt 0) {
            Write-Log -Message "" -Level Info
            Write-Log -Message "Update Runs for Cluster: $ClusterName" -Level Header
            Write-Log -Message ("=" * 60) -Level Header
            $formattedRuns | Format-Table -AutoSize | Out-String | Write-Host

            # If the latest run failed due to health check, show blocking health failures
            $latestRun = $formattedRuns | Select-Object -First 1
            if ($latestRun.State -eq "Failed" -and $latestRun.CurrentStep -match "health check") {
                Write-Log -Message "The latest update run was blocked by health check failures." -Level Warning
                Write-Log -Message "Querying current health check status..." -Level Info
                # -PassThru is required to receive the [PSCustomObject] result rows;
                # without it Test-AzLocalClusterHealth logs to the host only and
                # returns $null (v0.7.62 fix).
                $healthResults = Test-AzLocalClusterHealth -ClusterResourceIds @($clusterInfo.id) -BlockingOnly -PassThru
                if ($healthResults -and $healthResults[0].CriticalCount -gt 0) {
                    Write-Log -Message "" -Level Info
                    Write-Log -Message "The following critical health issues must be resolved before this update can proceed:" -Level Error
                    foreach ($failure in $healthResults[0].Failures) {
                        $nodeInfo = if ($failure.TargetResourceName) { " (Node: $($failure.TargetResourceName))" } else { "" }
                        Write-Log -Message " [Critical] $($failure.CheckName)$nodeInfo`: $($failure.Description)" -Level Error
                        if ($failure.Remediation) {
                            Write-Log -Message " Remediation: $($failure.Remediation)" -Level Warning
                        }
                    }
                }
            }
        }
        else {
            Write-Log -Message "" -Level Info
            Write-Log -Message "No update runs found for cluster '$ClusterName'" -Level Warning
        }

        # Display latest run details
        if ($formattedRuns.Count -gt 0) {
            Write-Log -Message "" -Level Info
            Write-Log -Message "Latest Update Run:" -Level Header
            Write-Host ""
            $formattedRuns | Select-Object -First 1 | Format-List | Out-String -Stream | ForEach-Object {
                if ($_ -ne "") { Write-Host "`t$_" }
            }
            Write-Host ""
        }

        # v0.7.1: Sideloaded auto-reset (default ON; -SkipSideloadedReset to disable).
        if (-not $SkipSideloadedReset -and $formattedRuns.Count -gt 0) {
            try {
                [void](Invoke-AzLocalSideloadedAutoReset -FormattedRuns $formattedRuns -ApiVersion $ApiVersion)
            }
            catch {
                Write-Log -Message "Sideloaded auto-reset failed: $($_.Exception.Message)" -Level Warning
            }
        }

        if ($PassThru) {
            return $formattedRuns
        }
        return
    }

    # Multi-cluster mode
    Write-Log -Message "" -Level Info
    Write-Log -Message "========================================" -Level Header
    Write-Log -Message "Azure Local Update Runs (Fleet)" -Level Header
    Write-Log -Message "========================================" -Level Header

    # Verify Azure CLI is installed and logged in
    Test-AzCliAvailable | Out-Null
    try {
        $null = az account show 2>$null
        if ($LASTEXITCODE -ne 0) {
            throw "Azure CLI is not logged in. Please run 'az login' first."
        }
        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
    }

    # v0.7.68: Multi-cluster mode is now ARG-only. Ensure the resource-graph
    # extension is present before any param-set branch runs (was previously
    # only checked in the ByTag branch).
    if (-not (Install-AzGraphExtension)) {
        Write-Log -Message "Failed to install Azure CLI 'resource-graph' extension." -Level Error
        return
    }

    # Build list of clusters to process
    $clustersToProcess = @()

    if ($PSCmdlet.ParameterSetName -eq 'ByTag') {
        Write-Log -Message "Querying Azure Resource Graph for clusters with tag 'UpdateRing' = '$UpdateRingValue'..." -Level Info

        $ringFilter = ConvertTo-AzLocalUpdateRingKqlFilter -UpdateRingValue $UpdateRingValue
        $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' $ringFilter | project id, name, resourceGroup, subscriptionId, tags"

        try {
            $argParams = @{ Query = $argQuery }
            if ($SubscriptionId) { $argParams['SubscriptionId'] = $SubscriptionId }
            $clusterRows = Invoke-AzResourceGraphQuery @argParams

            if (-not $clusterRows -or $clusterRows.Count -eq 0) {
                Write-Log -Message "No clusters found with tag 'UpdateRing' = '$UpdateRingValue'" -Level Warning
                return @()
            }

            Write-Log -Message "Found $($clusterRows.Count) cluster(s) matching tag criteria" -Level Success
            foreach ($cluster in $clusterRows) {
                $clustersToProcess += @{
                    ResourceId = $cluster.id
                    Name = $cluster.name
                    ResourceGroup = $cluster.resourceGroup
                    SubscriptionId = $cluster.subscriptionId
                }
            }
        }
        catch {
            Write-Log -Message "Error querying Azure Resource Graph: $_" -Level Error
            return
        }
    }
    elseif ($PSCmdlet.ParameterSetName -eq 'ByResourceId') {
        foreach ($resourceId in $ClusterResourceIds) {
            $clusterRgName = ($resourceId -split '/resourceGroups/')[1] -split '/' | Select-Object -First 1
            $clusterSubId = ($resourceId -split '/subscriptions/')[1] -split '/' | Select-Object -First 1
            $clustersToProcess += @{
                ResourceId = $resourceId
                Name = ($resourceId -split '/')[-1]
                ResourceGroup = $clusterRgName
                SubscriptionId = $clusterSubId
            }
        }
    }
    else {
        # ByName - v0.7.68: resolve all names in a SINGLE ARG batch lookup
        # instead of one ARM REST call per cluster. Works cross-subscription
        # when -SubscriptionId is not passed.
        $nameListKql = ($ClusterNames | ForEach-Object { "'$_'" }) -join ','
        $nameQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' | where name in~ ($nameListKql) | project id, name, resourceGroup, subscriptionId"
        try {
            $argParams = @{ Query = $nameQuery }
            if ($SubscriptionId) { $argParams['SubscriptionId'] = $SubscriptionId }
            $clusterRows = Invoke-AzResourceGraphQuery @argParams
        }
        catch {
            Write-Log -Message "Azure Resource Graph cluster lookup failed: $($_.Exception.Message)" -Level Error
            return
        }

        $foundNames = @($clusterRows | Select-Object -ExpandProperty name)
        Write-Log -Message "Resolved $($foundNames.Count) of $($ClusterNames.Count) cluster name(s) via Azure Resource Graph" -Level $(if ($foundNames.Count -eq $ClusterNames.Count) { 'Success' } else { 'Warning' })
        foreach ($name in $ClusterNames) {
            $match = $clusterRows | Where-Object { $_.name -ieq $name } | Select-Object -First 1
            if ($match) {
                $clustersToProcess += @{
                    ResourceId = $match.id
                    Name = $match.name
                    ResourceGroup = $match.resourceGroup
                    SubscriptionId = $match.subscriptionId
                }
            }
            else {
                Write-Log -Message "Cluster '$name' not found in Azure Resource Graph (subscription scope: $(if ($SubscriptionId) { $SubscriptionId } else { 'all readable' })) - skipping" -Level Warning
            }
        }
        if ($ResourceGroupName) {
            $clustersToProcess = @($clustersToProcess | Where-Object { $_.ResourceGroup -ieq $ResourceGroupName })
        }
    }

    if ($clustersToProcess.Count -eq 0) {
        Write-Log -Message "No clusters resolved for query - nothing to do." -Level Warning
        return @()
    }

    Write-Log -Message "" -Level Info
    Write-Log -Message "Querying update runs for $($clustersToProcess.Count) cluster(s)..." -Level Info

    # Collect results
    $allFormattedRuns = [System.Collections.Generic.List[object]]::new()
    $stateCounts = @{}

    # v0.7.68: Replaced per-cluster ARM REST fan-out with a SINGLE Azure
    # Resource Graph query against the `extensibilityresources` namespace
    # (microsoft.azurestackhci/clusters/updates/updateruns). One round-trip
    # returns every update run for the entire cluster list - typically in
    # <10 seconds for hundreds of clusters - replacing the previous design
    # which made one ARM REST call per update per cluster (251s for 9
    # clusters in the smoke test). The `properties` bag returned by ARG is
    # identical in shape to the ARM REST /updateRuns response, so we can
    # reuse Format-AzLocalUpdateRun unchanged.

    # Build the KQL `in~()` literal: cluster IDs are lowercased to match the
    # `tolower(...)` projection inside the query. PowerShell single-quoted
    # strings in the join are valid KQL string literals because cluster IDs
    # cannot contain apostrophes.
    $idListKql = ($clustersToProcess | ForEach-Object { "'$($_.ResourceId.ToLower())'" }) -join ','
    $updateNameClause = if ($UpdateName) { "| where UpdateName_ =~ '$UpdateName'" } else { '' }

    $runsKql = @"
extensibilityresources
| where type =~ 'microsoft.azurestackhci/clusters/updates/updateruns'
| extend ids = split(id, '/')
| extend ClusterName_ = tostring(ids[8]), UpdateName_ = tostring(ids[10])
| extend ClusterResourceId_ = tolower(strcat('/subscriptions/', tostring(ids[2]), '/resourceGroups/', tostring(ids[4]), '/providers/Microsoft.AzureStackHCI/clusters/', ClusterName_))
| where ClusterResourceId_ in~ ($idListKql)
$updateNameClause
| project id, name, type, location, properties, ClusterName_, ClusterResourceId_, UpdateName_, ts = todatetime(properties.timeStarted)
| order by ts desc
"@


    try {
        $argParams = @{ Query = $runsKql }
        if ($SubscriptionId) { $argParams['SubscriptionId'] = $SubscriptionId }
        $allRunsRaw = Invoke-AzResourceGraphQuery @argParams
    }
    catch {
        Write-Log -Message "Azure Resource Graph query for update runs failed: $($_.Exception.Message)" -Level Error
        return
    }

    Write-Log -Message "Returned $($allRunsRaw.Count) update run(s) across $($clustersToProcess.Count) cluster(s) via Azure Resource Graph" -Level Success

    # Group rows by ClusterResourceId (lowercased) and build the per-cluster
    # entry table that the downstream UX loop expects. The shape mirrors the
    # legacy parallel-jobs output: { ClusterName, DisplayTag, LatestState,
    # RunCount, Rows[] } where Rows[] is the Format-AzLocalUpdateRun output.
    $runsByCluster = @{}
    foreach ($row in $allRunsRaw) {
        $key = [string]$row.ClusterResourceId_
        if (-not $runsByCluster.ContainsKey($key)) { $runsByCluster[$key] = [System.Collections.Generic.List[object]]::new() }
        $runsByCluster[$key].Add($row) | Out-Null
    }

    $perCluster = @{}
    foreach ($cluster in $clustersToProcess) {
        $key = $cluster.ResourceId.ToLower()
        # NOTE: Do not use `$x = if (...) { @($h[$key]) } else { @() }` here -
        # the `if` block's pipeline return unwraps single-element Object[] to
        # the bare element under PowerShell 5.1, and PSCustomObject.Count is
        # empty (not 1), which would silently mask any cluster having exactly
        # one update run. Assign default then overwrite to preserve array.
        $clusterRuns = @()
        if ($runsByCluster.ContainsKey($key)) { $clusterRuns = @($runsByCluster[$key]) }

        if ($clusterRuns.Count -gt 0) {
            # ARG ordering is already StartTime desc but re-sort defensively
            # in case Resource Graph re-orders rows during pagination.
            $sorted = @($clusterRuns | Sort-Object { $_.properties.timeStarted } -Descending)
            $latestRun = $sorted[0]
            $latestState = [string]$latestRun.properties.state
            $runsToFormat = if ($Latest) { @($latestRun) } else { $sorted }

            $rows = foreach ($run in $runsToFormat) {
                $formatted = Format-AzLocalUpdateRun -run $run -clusterName $cluster.Name -clusterResourceId $cluster.ResourceId
                [PSCustomObject]@{
                    ClusterName       = $cluster.Name
                    ClusterResourceId = $cluster.ResourceId
                    UpdateName        = $formatted.UpdateName
                    RunId             = $formatted.RunId
                    State             = $formatted.State
                    StartTime         = $formatted.StartTime
                    EndTime           = $formatted.EndTime
                    Duration          = $formatted.Duration
                    Progress          = $formatted.Progress
                    CurrentStep       = $formatted.CurrentStep
                    CurrentStepDetail = $formatted.CurrentStepDetail
                    Location          = $formatted.Location
                }
            }

            $perCluster[$cluster.Name] = [PSCustomObject]@{
                ClusterName = $cluster.Name
                DisplayTag  = 'Runs'
                LatestState = $latestState
                RunCount    = $clusterRuns.Count
                Rows        = @($rows)
            }
        }
        else {
            $perCluster[$cluster.Name] = [PSCustomObject]@{
                ClusterName = $cluster.Name
                DisplayTag  = 'NoRuns'
                LatestState = $null
                RunCount    = 0
                Rows        = @([PSCustomObject]@{
                        ClusterName       = $cluster.Name
                        ClusterResourceId = $cluster.ResourceId
                        UpdateName        = 'None'
                        RunId             = ''
                        State             = 'No Runs'
                        StartTime         = ''
                        EndTime           = ''
                        Duration          = ''
                        Progress          = ''
                        CurrentStep       = ''
                        CurrentStepDetail = ''
                        Location          = ''
                    })
            }
        }
    }

    foreach ($cluster in $clustersToProcess) {
        $entry = $perCluster[$cluster.Name]
        if (-not $entry) { continue }

        Write-Host " Checking: $($cluster.Name)..." -ForegroundColor Gray -NoNewline
        switch -Regex ($entry.DisplayTag) {
            '^NotFound$' { Write-Host ' Not Found' -ForegroundColor Red }
            '^NoRuns$'   { Write-Host ' No runs' -ForegroundColor Gray }
            '^Error:(.*)' { Write-Host " Error: $($matches[1])" -ForegroundColor Red }
            '^Runs$' {
                $stateColor = switch ($entry.LatestState) {
                    'Succeeded'  { 'Green' }
                    'InProgress' { 'Yellow' }
                    'Failed'     { 'Red' }
                    default      { 'Gray' }
                }
                Write-Host " $($entry.RunCount) run(s), latest: $($entry.LatestState)" -ForegroundColor $stateColor

                if ($entry.LatestState) {
                    if ($stateCounts.ContainsKey($entry.LatestState)) {
                        $stateCounts[$entry.LatestState]++
                    }
                    else {
                        $stateCounts[$entry.LatestState] = 1
                    }
                }
            }
            default { Write-Host '' -ForegroundColor Gray }
        }

        foreach ($row in @($entry.Rows)) {
            $allFormattedRuns.Add($row) | Out-Null
        }
    }

    # Display Summary
    Write-Log -Message "" -Level Info
    Write-Log -Message "========================================" -Level Header
    Write-Log -Message "Summary" -Level Header
    Write-Log -Message "========================================" -Level Header
    
    $totalClusters = $clustersToProcess.Count
    Write-Log -Message "" -Level Info
    Write-Log -Message "Total Clusters: $totalClusters" -Level Info
    
    if ($stateCounts.Count -gt 0) {
        Write-Log -Message "Latest Run States:" -Level Header
        foreach ($state in $stateCounts.Keys | Sort-Object) {
            $level = switch ($state) {
                "Succeeded" { "Success" }
                "Failed" { "Error" }
                "InProgress" { "Warning" }
                default { "Info" }
            }
            Write-Log -Message " $state`: $($stateCounts[$state])" -Level $level
        }
    }

    # Display results table
    Write-Log -Message "" -Level Info
    Write-Log -Message "Update Runs:" -Level Header
    $allFormattedRuns | Format-Table ClusterName, UpdateName, State, StartTime, EndTime, Duration, Progress -AutoSize | Out-Host

    # Check for health-check-blocked failures and show diagnostics
    $healthBlockedRuns = @($allFormattedRuns | Where-Object { $_.State -eq "Failed" -and $_.CurrentStep -match "health check" })
    if ($healthBlockedRuns.Count -gt 0) {
        $affectedClusters = @($healthBlockedRuns | Select-Object -ExpandProperty ClusterName -Unique)
        Write-Log -Message "" -Level Info
        Write-Log -Message "Detected $($healthBlockedRuns.Count) update run(s) blocked by health check failures." -Level Warning
        Write-Log -Message "Querying current health check status for affected cluster(s)..." -Level Info
        
        foreach ($affectedCluster in $affectedClusters) {
            # Find the resource ID for this cluster from the clusters we already processed
            $clusterEntry = $clustersToProcess | Where-Object { $_.Name -eq $affectedCluster }
            $rid = $clusterEntry.ResourceId
            if (-not $rid) { continue }
            
            # -PassThru required (v0.7.62 fix); see Step 3b in Start-AzLocalClusterUpdate.
            $healthResults = Test-AzLocalClusterHealth -ClusterResourceIds @($rid) -BlockingOnly -PassThru
            if ($healthResults -and $healthResults[0].CriticalCount -gt 0) {
                Write-Log -Message "" -Level Info
                Write-Log -Message "Critical health issues blocking updates on '$affectedCluster':" -Level Error
                foreach ($failure in $healthResults[0].Failures) {
                    $nodeInfo = if ($failure.TargetResourceName) { " (Node: $($failure.TargetResourceName))" } else { "" }
                    Write-Log -Message " [Critical] $($failure.CheckName)$nodeInfo`: $($failure.Description)" -Level Error
                    if ($failure.Remediation) {
                        Write-Log -Message " Remediation: $($failure.Remediation)" -Level Warning
                    }
                }
            }
        }
    }

    # Export if path specified
    if ($ExportPath) {
        try {
            $ExportPath = Resolve-SafeOutputPath -Path $ExportPath
            $exportDir = Split-Path -Path $ExportPath -Parent
            if ($exportDir -and -not (Test-Path $exportDir)) {
                New-Item -ItemType Directory -Path $exportDir -Force | Out-Null
            }
            
            $format = Get-ExportFormat -Path $ExportPath -ExportFormat $ExportFormat
            
            switch ($format) {
                'Csv' {
                    $allFormattedRuns | ConvertTo-SafeCsvCollection | Export-Csv -Path $ExportPath -NoTypeInformation -Encoding UTF8
                    Write-Log -Message "Results exported to CSV: $ExportPath" -Level Success
                }
                'Json' {
                    $exportData = @{
                        Timestamp     = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
                        TotalClusters = $totalClusters
                        StateSummary  = $stateCounts
                        Results       = $allFormattedRuns
                    }
                    Write-Utf8NoBomFile -Path $ExportPath -Content ($exportData | ConvertTo-Json -Depth 10)
                    Write-Log -Message "Results exported to JSON: $ExportPath" -Level Success
                }
                'JUnitXml' {
                    $junitResults = $allFormattedRuns | ForEach-Object {
                        [PSCustomObject]@{
                            ClusterName  = $_.ClusterName
                            Status       = if ($_.State -eq "Succeeded") { "Passed" } elseif ($_.State -in @("Failed", "Error")) { "Failed" } else { "Skipped" }
                            Message      = "Update: $($_.UpdateName), State: $($_.State), Duration: $($_.Duration), Progress: $($_.Progress)"
                            UpdateName   = $_.UpdateName
                            CurrentState = $_.State
                            StartTime    = $_.StartTime
                            EndTime      = $_.EndTime
                            Duration     = $_.Duration
                            Progress     = $_.Progress
                        }
                    }
                    Export-ResultsToJUnitXml -Results $junitResults -OutputPath $ExportPath `
                        -TestSuiteName "AzureLocalUpdateRuns" -OperationType "UpdateRuns"
                    Write-Log -Message "Results exported to JUnit XML: $ExportPath" -Level Success
                }
            }
        }
        catch {
            Write-Log -Message "Failed to export results: $($_.Exception.Message)" -Level Error
        }
    }

    Write-Log -Message "" -Level Info

    # Display latest run details per cluster
    if ($allFormattedRuns.Count -gt 0) {
        $latestPerCluster = $allFormattedRuns | Group-Object ClusterName | ForEach-Object {
            $_.Group | Sort-Object StartTime -Descending | Select-Object -First 1
        }
        Write-Log -Message "Latest Update Run per Cluster:" -Level Header
        Write-Host ""
        $latestPerCluster | Format-List | Out-String -Stream | ForEach-Object {
            if ($_ -ne "") { Write-Host "`t$_" }
        }
        Write-Host ""
    }

    # v0.7.1: Sideloaded auto-reset (default ON; -SkipSideloadedReset to disable).
    if (-not $SkipSideloadedReset -and $allFormattedRuns.Count -gt 0) {
        try {
            [void](Invoke-AzLocalSideloadedAutoReset -FormattedRuns $allFormattedRuns -ApiVersion $ApiVersion)
        }
        catch {
            Write-Log -Message "Sideloaded auto-reset failed: $($_.Exception.Message)" -Level Warning
        }
    }

    if ($PassThru) {
        return $allFormattedRuns
    }
}