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 runs use AzureScout's built-in renderer. A small in-process .NET timer owns the spinner and elapsed clock, so both continue moving while the PowerShell thread is blocked in an Azure SDK or REST call. Phase updates change the active line instead of printing static styled output. The renderer has no external module dependency and never changes PowerShell repository trust. It presents a bordered, multi-phase ledger: finished phases remain visible while the active phase keeps a moving spinner, progress bar, status and elapsed clock. It uses bright foreground colours only (never a coloured background) and falls back to native Write-Progress or log-friendly information records in redirected, CI, or suppressed hosts. Rendering failures can never cause Azure work to execute twice. #> function Test-ScoutNativeLiveHost { [CmdletBinding()] param([switch] $Force) if ($ProgressPreference -eq 'SilentlyContinue') { $script:ScoutNativeProgressDecision = 'disabled: ProgressPreference is SilentlyContinue' return $false } if ($Force) { $script:ScoutNativeProgressDecision = 'eligible: forced by caller' return $true } if ($env:CI -or $env:TF_BUILD -or $env:GITHUB_ACTIONS -or $env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { $script:ScoutNativeProgressDecision = 'disabled: CI environment detected' return $false } if (-not [Environment]::UserInteractive) { $script:ScoutNativeProgressDecision = 'disabled: process is not user-interactive' return $false } try { if ([Console]::IsOutputRedirected) { $script:ScoutNativeProgressDecision = 'disabled: console output is redirected' return $false } } catch { $script:ScoutNativeProgressDecision = 'disabled: console redirection state is unavailable' return $false } if ($null -eq $Host -or $Host.Name -in @('Default Host', 'ServerRemoteHost')) { $hostLabel = if ($null -eq $Host) { '<null>' } else { $Host.Name } $script:ScoutNativeProgressDecision = "disabled: unsupported host '$hostLabel'" return $false } $script:ScoutNativeProgressDecision = "eligible: interactive $($Host.Name) host" return $true } function Write-ScoutProgressDiagnostic { [CmdletBinding()] param([Parameter(Mandatory)] [string] $Message) try { if (Get-Command Write-AZSCLog -ErrorAction SilentlyContinue) { Write-AZSCLog -Level 'DEBUG' -Message "Live progress: $Message" } } catch { Write-Verbose "AzureScout could not write the live-progress diagnostic: $($_.Exception.Message)" } } function Initialize-ScoutNativeProgressRenderer { [CmdletBinding()] param() if ('AzureScout.NativeProgressRenderer' -as [type]) { return $true } $source = @' using System; using System.Diagnostics; using System.Text; using System.Threading; namespace AzureScout { public static class NativeProgressRenderer { private static readonly object Gate = new object(); private static readonly string[] Frames = new[] { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }; private const string BrightCyan = "\u001b[96;1m"; private const string BrightGreen = "\u001b[92;1m"; private const string BrightRed = "\u001b[91;1m"; private const string BrightYellow = "\u001b[93;1m"; private const string Dim = "\u001b[90m"; private const string Reset = "\u001b[0m"; private static Timer _timer; private static Stopwatch _stopwatch; private static string _activity = "Azure Scout"; private static string _status = "Starting..."; private static string _rootActivity = "Azure Scout"; private static string _taskKey = String.Empty; private static int _percent = -1; private static int _frame; private static int _renderCount; private static int _liveRow = -1; private static bool _ansi; private static bool _active; private static bool _hasLiveRow; public static bool IsActive { get { lock (Gate) { return _active; } } } public static int RenderCount { get { lock (Gate) { return _renderCount; } } } public static void Start(string activity, string status, int percent, bool ansi) { lock (Gate) { DisposeTimer(); _activity = Clean(activity, "Azure Scout"); _rootActivity = _activity; _status = Clean(status, "Starting..."); _taskKey = "1|" + _activity; _percent = ClampPercent(percent); _ansi = ansi; _frame = 0; _renderCount = 0; _liveRow = -1; _hasLiveRow = true; _stopwatch = Stopwatch.StartNew(); _active = true; WriteHeader(); RenderLocked(); _timer = new Timer(RenderTick, null, TimeSpan.FromMilliseconds(125), TimeSpan.FromMilliseconds(125)); } } public static void Update(string activity, string status, int percent, bool completed, int id, int parentId) { lock (Gate) { if (!_active) return; string nextActivity = Clean(activity, _activity); string nextKey = id.ToString() + "|" + nextActivity; if (!String.Equals(nextKey, _taskKey, StringComparison.OrdinalIgnoreCase)) { FinishLiveRow(false, "phase changed"); _activity = nextActivity; _taskKey = nextKey; _hasLiveRow = true; _liveRow = GetCursorTop(); } else { _activity = nextActivity; } _status = Clean(status, _status); _percent = completed ? 100 : ClampPercent(percent); if (completed) { FinishLiveRow(true, _status); } else { RenderLocked(); } } } public static void Stop(bool succeeded, string status) { lock (Gate) { if (!_active) return; _active = false; DisposeTimer(); if (_stopwatch != null) _stopwatch.Stop(); try { string elapsed = _stopwatch == null ? "00:00:00" : _stopwatch.Elapsed.ToString(@"hh\:mm\:ss"); string finalStatus = Clean(status, succeeded ? "Complete" : "Failed"); if (_hasLiveRow) FinishLiveRow(succeeded, finalStatus); WriteFooter(succeeded, _rootActivity, finalStatus, elapsed); } catch { // Progress must never affect the operation result. } } } private static void RenderTick(object state) { lock (Gate) { if (!_active) return; try { _renderCount++; RenderLocked(); } catch { _active = false; DisposeTimer(); } } } private static void RenderLocked() { if (!_active || !_hasLiveRow) return; int currentTop = GetCursorTop(); if (_liveRow < 0 || currentTop != _liveRow) { // A warning or host message was written while the timer owned the live row. // Move the renderer to the new cursor row so that message remains visible. _liveRow = currentTop; } int width = GetWidth(); string frame = Frames[_frame++ % Frames.Length]; string activity = Truncate(_activity, 26); string bar = BuildBar(_percent, 16, _frame); string percent = _percent < 0 ? " --%" : String.Format("{0,3}%", _percent); string elapsed = _stopwatch == null ? "00:00:00" : _stopwatch.Elapsed.ToString(@"hh\:mm\:ss"); string body = String.Format("{0} {1} [{2}] {3} {4} {5}", frame, activity, bar, percent, elapsed, _status); string plain = PanelLine(body, width); string rendered = _ansi ? BrightCyan + plain + Reset : plain; // Return to column zero after every tick. Normal PowerShell host output can then // replace the live row cleanly; the next tick detects the cursor-row change and // continues beneath it instead of appending progress text to the warning. Console.Write("\r" + rendered + "\r"); _liveRow = GetCursorTop(); } private static void FinishLiveRow(bool succeeded, string status) { if (!_hasLiveRow) return; string elapsed = _stopwatch == null ? "00:00:00" : _stopwatch.Elapsed.ToString(@"hh\:mm\:ss"); string symbol = succeeded ? "✓" : "→"; string body = String.Format("{0} {1} — {2} ({3})", symbol, _activity, Clean(status, _status), elapsed); string plain = PanelLine(body, GetWidth()); string colour = succeeded ? BrightGreen : BrightYellow; string rendered = _ansi ? colour + plain + Reset : plain; Console.Write("\r" + rendered + Environment.NewLine); _hasLiveRow = false; _liveRow = GetCursorTop(); } private static void WriteHeader() { int width = GetWidth(); string title = " Azure Scout — live progress "; int remaining = Math.Max(1, width - title.Length - 2); string plain = "╭" + title + new string('─', remaining) + "╮"; plain = Truncate(plain, width); Console.WriteLine(_ansi ? BrightCyan + plain + Reset : plain); _liveRow = GetCursorTop(); } private static void WriteFooter(bool succeeded, string activity, string status, string elapsed) { int width = GetWidth(); string summary = String.Format(" {0} {1}: {2}; elapsed {3} ", succeeded ? "✓" : "✗", activity, status, elapsed); summary = Truncate(summary, Math.Max(1, width - 2)); int remaining = Math.Max(1, width - summary.Length - 2); string plain = "╰" + summary + new string('─', remaining) + "╯"; plain = Truncate(plain, width); string colour = succeeded ? BrightGreen : BrightRed; Console.WriteLine(_ansi ? colour + plain + Reset : plain); _liveRow = GetCursorTop(); } private static string PanelLine(string body, int width) { int innerWidth = Math.Max(1, width - 4); string inner = Truncate(Clean(body, String.Empty), innerWidth); return "│ " + inner + Spaces(Math.Max(0, innerWidth - inner.Length)) + " │"; } private static string BuildBar(int percent, int width, int frame) { if (percent < 0) { int position = frame % width; return new string('─', position) + "●" + new string('─', Math.Max(0, width - position - 1)); } int filled = (int)Math.Round(width * (Math.Min(100, Math.Max(0, percent)) / 100.0)); return new string('█', filled) + new string('─', width - filled); } private static int ClampPercent(int percent) { return percent < 0 ? -1 : Math.Min(100, Math.Max(0, percent)); } private static string Clean(string value, string fallback) { if (String.IsNullOrWhiteSpace(value)) return fallback ?? String.Empty; var builder = new StringBuilder(value.Length); foreach (char character in value) { if (character == '\r' || character == '\n' || character == '\u001b') builder.Append(' '); else if (!Char.IsControl(character)) builder.Append(character); } return builder.ToString().Trim(); } private static string Truncate(string value, int width) { if (width <= 0) return String.Empty; if (String.IsNullOrEmpty(value) || value.Length <= width) return value ?? String.Empty; if (width == 1) return "…"; return value.Substring(0, width - 1) + "…"; } private static int GetWidth() { try { return Math.Max(72, Console.WindowWidth); } catch { return 120; } } private static int GetCursorTop() { try { return Console.CursorTop; } catch { return -1; } } private static string Spaces(int count) { return count <= 0 ? String.Empty : new string(' ', count); } private static void DisposeTimer() { if (_timer == null) return; try { _timer.Change(Timeout.Infinite, Timeout.Infinite); } catch { } try { _timer.Dispose(); } catch { } _timer = null; } } } '@ try { Add-Type -TypeDefinition $source -Language CSharp -ErrorAction Stop return $true } catch { Write-Verbose "AzureScout native progress renderer could not initialize: $($_.Exception.Message)" return $false } } function Start-ScoutNativeProgressHost { [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; Error = $null; Output = @() } $hadActive = Test-Path Variable:script:ScoutNativeProgressActive $previousActive = if ($hadActive) { $script:ScoutNativeProgressActive } else { $false } $supportsAnsi = $false try { $supportsAnsi = [bool]$Host.UI.SupportsVirtualTerminal } catch { $supportsAnsi = $false } try { [AzureScout.NativeProgressRenderer]::Start($Activity, $Status, $PercentComplete, $supportsAnsi) $script:ScoutNativeProgressActive = $true $state.Started = $true Write-ScoutProgressDiagnostic -Message ( "renderer started; activity={0}; host={1}; virtualTerminal={2}" -f $Activity, $Host.Name, $supportsAnsi ) try { $state.Output = @(& $Operation @ArgumentList) } catch { $state.Error = $_ } } finally { try { [AzureScout.NativeProgressRenderer]::Stop( ($null -eq $state.Error), $(if ($null -eq $state.Error) { 'Complete' } else { 'Failed' }) ) } catch { Write-Verbose "AzureScout progress summary could not be rendered: $($_.Exception.Message)" } if ($hadActive) { $script:ScoutNativeProgressActive = $previousActive } else { Remove-Variable -Name ScoutNativeProgressActive -Scope Script -ErrorAction SilentlyContinue } } if ($null -ne $state.Error) { $state.Error.Exception.Data['ScoutProgressOperationStarted'] = $true throw $state.Error } 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-ScoutNativeLiveHost)) { $decision = if (Get-Variable -Name ScoutNativeProgressDecision -Scope Script -ErrorAction SilentlyContinue) { [string]$script:ScoutNativeProgressDecision } else { 'disabled: live console unavailable' } Write-ScoutProgressDiagnostic -Message $decision return (& $Operation @ArgumentList) } if (-not (Initialize-ScoutNativeProgressRenderer)) { Write-ScoutProgressDiagnostic -Message 'disabled: built-in renderer failed to initialize' return (& $Operation @ArgumentList) } try { return Start-ScoutNativeProgressHost -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 Write-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 } if ((Test-Path Variable:script:ScoutNativeProgressActive) -and $script:ScoutNativeProgressActive) { try { [AzureScout.NativeProgressRenderer]::Update( $Activity, $displayStatus, $PercentComplete, $Completed.IsPresent, $Id, $ParentId ) return } catch { Write-Verbose "Write-ScoutProgress: live 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)" } } } |