src/Write-ScoutProgress.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Live, resilient progress reporting for AzureScout (AB#405). .DESCRIPTION Interactive extraction can run inside a real PwshSpectreConsole progress host. Spectre's auto-refresh thread keeps the spinner and elapsed-time column moving while the PowerShell thread is blocked in an Azure SDK or REST call. Phase updates change the live task instead of printing static styled lines. PwshSpectreConsole remains an optional dependency so AzureScout keeps its PowerShell 7.0 compatibility. When the module is absent, the host is redirected, CI is detected, or progress is suppressed, the same calls fall back to native Write-Progress or log-friendly Information records. Progress rendering can never cause Azure work to execute twice. #> function Test-ScoutSpectreAvailable { [CmdletBinding()] param([switch] $Force) if ($ProgressPreference -eq 'SilentlyContinue') { return $false } if (-not $Force) { if ($env:CI -or $env:TF_BUILD -or $env:GITHUB_ACTIONS -or $env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { return $false } if (-not [Environment]::UserInteractive) { return $false } try { if ([Console]::IsOutputRedirected) { return $false } } catch { return $false } if ($null -eq $Host -or $Host.Name -in @('Default Host', 'ServerRemoteHost')) { return $false } } try { return [bool]( Get-Module -ListAvailable -Name PwshSpectreConsole -ErrorAction SilentlyContinue | Where-Object { $_.Version -ge [version]'2.1.2' } | Select-Object -First 1 ) } catch { return $false } } function Import-ScoutSpectreConsole { [CmdletBinding()] param() if (Get-Module -Name PwshSpectreConsole -ErrorAction SilentlyContinue) { return $true } try { Import-Module PwshSpectreConsole -MinimumVersion 2.1.2 -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null return $true } catch { Write-Verbose "PwshSpectreConsole is unavailable; using native progress: $($_.Exception.Message)" return $false } } function Get-ScoutProgressTaskKey { param([Parameter(Mandatory)] [string] $Activity, [Parameter(Mandatory)] [int] $Id) return '{0}|{1}' -f $Id, $Activity } function ConvertTo-ScoutSpectreText { param([AllowEmptyString()] [string] $Text) try { return [Spectre.Console.Markup]::Escape($Text) } catch { return ($Text -replace '\[', '[[' -replace '\]', ']]') } } function New-ScoutSpectreDescription { param( [Parameter(Mandatory)] [string] $Activity, [AllowEmptyString()] [string] $Status ) $safeActivity = ConvertTo-ScoutSpectreText $Activity $safeStatus = ConvertTo-ScoutSpectreText $Status # No coloured background: the terminal's own foreground remains readable in light and dark # themes. Cyan and white reinforce state, while the labels themselves carry the meaning. return "[bold cyan1]$safeActivity[/] [white]$safeStatus[/]" } function Start-ScoutSpectreProgressHost { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Activity, [string] $Status = 'Starting...', [ValidateRange(0, 100)] [int] $PercentComplete = 0, [int] $Id = 1, [Parameter(Mandatory)] [scriptblock] $Operation, [object[]] $ArgumentList = @() ) $state = [pscustomobject]@{ Started = $false Completed = $false Error = $null Output = @() } $timer = [Diagnostics.Stopwatch]::StartNew() $hadContext = Test-Path Variable:script:ScoutSpectreProgressContext $hadTasks = Test-Path Variable:script:ScoutSpectreProgressTasks $previousContext = if ($hadContext) { $script:ScoutSpectreProgressContext } else { $null } $previousTasks = if ($hadTasks) { $script:ScoutSpectreProgressTasks } else { $null } try { $columns = [Spectre.Console.ProgressColumn[]]@( [Spectre.Console.SpinnerColumn]::new(), [Spectre.Console.TaskDescriptionColumn]::new(), [Spectre.Console.ProgressBarColumn]::new(), [Spectre.Console.PercentageColumn]::new(), [Spectre.Console.ElapsedTimeColumn]::new() ) $progress = [Spectre.Console.AnsiConsole]::Progress() [void][Spectre.Console.ProgressExtensions]::Columns($progress, $columns) [void][Spectre.Console.ProgressExtensions]::AutoRefresh($progress, $true) [void][Spectre.Console.ProgressExtensions]::AutoClear($progress, $false) [void][Spectre.Console.ProgressExtensions]::HideCompleted($progress, $false) $progress.RefreshRate = [TimeSpan]::FromMilliseconds(100) $callback = [System.Action[Spectre.Console.ProgressContext]] { param($context) $tasks = [System.Collections.Generic.Dictionary[string, object]]::new( [System.StringComparer]::OrdinalIgnoreCase ) $description = New-ScoutSpectreDescription -Activity $Activity -Status $Status $task = $context.AddTask($description) $task.Value = [Math]::Min(100, [Math]::Max(0, $PercentComplete)) $tasks[(Get-ScoutProgressTaskKey -Activity $Activity -Id $Id)] = $task $script:ScoutSpectreProgressContext = $context $script:ScoutSpectreProgressTasks = $tasks $state.Started = $true try { $state.Output = @(& $Operation @ArgumentList) $state.Completed = $true $task.IsIndeterminate = $false $task.Value = 100 $task.Description = New-ScoutSpectreDescription -Activity $Activity -Status 'Complete' $task.StopTask() } catch { $state.Error = $_ $task.IsIndeterminate = $false $task.Description = New-ScoutSpectreDescription -Activity $Activity -Status 'Failed' $task.StopTask() } } try { $progress.Start($callback) } catch { # Once the operation starts it is unsafe to fall back by invoking it again. Mark the # exception so the outer wrapper propagates instead of duplicating Azure calls. if ($state.Started) { $_.Exception.Data['ScoutProgressOperationStarted'] = $true } throw } } finally { $timer.Stop() if ($hadContext) { $script:ScoutSpectreProgressContext = $previousContext } else { Remove-Variable -Name ScoutSpectreProgressContext -Scope Script -ErrorAction SilentlyContinue } if ($hadTasks) { $script:ScoutSpectreProgressTasks = $previousTasks } else { Remove-Variable -Name ScoutSpectreProgressTasks -Scope Script -ErrorAction SilentlyContinue } } if ($null -ne $state.Error) { $state.Error.Exception.Data['ScoutProgressOperationStarted'] = $true throw $state.Error } try { $safeActivity = ConvertTo-ScoutSpectreText $Activity Write-SpectreHost ('[green]✓[/] [bold]{0} complete[/] [white]elapsed {1}[/]' -f $safeActivity, $timer.Elapsed.ToString('hh\:mm\:ss')) } catch { Write-Verbose "AzureScout progress summary could not be rendered: $($_.Exception.Message)" } return $state.Output } function Invoke-ScoutProgressOperation { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Activity, [string] $Status = 'Starting...', [ValidateRange(0, 100)] [int] $PercentComplete = 0, [int] $Id = 1, [Parameter(Mandatory)] [scriptblock] $Operation, [object[]] $ArgumentList = @() ) if (-not (Test-ScoutSpectreAvailable)) { return (& $Operation @ArgumentList) } if (-not (Import-ScoutSpectreConsole)) { return (& $Operation @ArgumentList) } try { return Start-ScoutSpectreProgressHost -Activity $Activity -Status $Status ` -PercentComplete $PercentComplete -Id $Id -Operation $Operation -ArgumentList $ArgumentList } catch { if ($_.Exception.Data.Contains('ScoutProgressOperationStarted')) { throw } Write-Verbose "AzureScout live progress could not start; using native progress: $($_.Exception.Message)" return (& $Operation @ArgumentList) } } function Write-ScoutProgress { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Activity, [string] $Status = 'Working...', [string] $CurrentOperation, [int] $PercentComplete = -1, [int] $Id = 1, [int] $ParentId = -1, [switch] $Completed ) $displayStatus = if ([string]::IsNullOrWhiteSpace($CurrentOperation)) { $Status } elseif ([string]::IsNullOrWhiteSpace($Status)) { $CurrentOperation } else { '{0} — {1}' -f $Status, $CurrentOperation } # A Spectre host is active around the long-running operation. Updating its task is fast; # Spectre's own refresh thread owns the spinner and elapsed clock between these calls. if ( (Test-Path Variable:script:ScoutSpectreProgressContext) -and (Test-Path Variable:script:ScoutSpectreProgressTasks) -and $null -ne $script:ScoutSpectreProgressContext -and $null -ne $script:ScoutSpectreProgressTasks ) { try { $key = Get-ScoutProgressTaskKey -Activity $Activity -Id $Id if (-not $script:ScoutSpectreProgressTasks.ContainsKey($key)) { $newTask = $script:ScoutSpectreProgressContext.AddTask( (New-ScoutSpectreDescription -Activity $Activity -Status $displayStatus) ) $script:ScoutSpectreProgressTasks[$key] = $newTask } $task = $script:ScoutSpectreProgressTasks[$key] $task.Description = New-ScoutSpectreDescription -Activity $Activity -Status $displayStatus if ($Completed) { $task.IsIndeterminate = $false $task.Value = 100 $task.StopTask() } elseif ($PercentComplete -ge 0) { $task.IsIndeterminate = $false $task.Value = [Math]::Min(100, [Math]::Max(0, $PercentComplete)) } else { $task.IsIndeterminate = $true } return } catch { Write-Verbose "Write-ScoutProgress: live Spectre update failed, falling back to Write-Progress: $($_.Exception.Message)" } } try { $progressParams = @{ Activity = $Activity; Status = $displayStatus; Id = $Id } if ($ParentId -ge 0) { $progressParams.ParentId = $ParentId } if ($Completed) { $progressParams.Completed = $true } elseif ($PercentComplete -ge 0) { $progressParams.PercentComplete = [Math]::Min(100, $PercentComplete) } Write-Progress @progressParams } catch { Write-Verbose "Write-ScoutProgress: Write-Progress failed, continuing without a progress bar: $($_.Exception.Message)" } if (-not $Completed -and $ProgressPreference -eq 'SilentlyContinue') { try { $pctText = if ($PercentComplete -ge 0) { "$PercentComplete%" } else { '...' } Write-Information "[$Activity] $pctText $displayStatus" -InformationAction Continue } catch { Write-Verbose "Write-ScoutProgress: log-line fallback failed: $($_.Exception.Message)" } } } |