private/progress/Start-ZtProgressServer.ps1
|
function Start-ZtProgressServer { <# .SYNOPSIS Starts a local HTTP server that serves the progress dashboard. .DESCRIPTION Creates a background runspace running a System.Net.HttpListener that serves: GET / - The progress dashboard HTML page GET /api/progress - JSON snapshot of current progress state The server reads from the ProgressState ConcurrentDictionary which is shared across all runspaces in the process via PSFDynamicContentObject. The server tries ports 8924-8934 and uses the first available one. .EXAMPLE PS C:\> Start-ZtProgressServer Starts the progress server and prints the URL to the console. #> [CmdletBinding()] param () process { # Load the HTML content from the assets folder $htmlPath = Join-Path $script:ModuleRoot 'assets' 'progress.html' if (-not (Test-Path $htmlPath)) { Write-PSFMessage -Level Warning -Message "Progress dashboard HTML not found at '$htmlPath'. Progress server will not start." return } $htmlContent = [System.IO.File]::ReadAllText($htmlPath) # Get the ConcurrentDictionary backing the ProgressState DCO $progressDict = $script:__ZtSession.ProgressState.Value # Try to find an available port $selectedPort = $null foreach ($port in 8924..8934) { try { $testListener = [System.Net.HttpListener]::new() $testListener.Prefixes.Add("http://localhost:$port/") $testListener.Start() $testListener.Stop() $testListener.Close() $selectedPort = $port break } catch { continue } } if (-not $selectedPort) { Write-PSFMessage -Level Warning -Message "Could not find an available port (tried 8924-8934). Progress server will not start." return } $url = "http://localhost:$selectedPort" # Create a dedicated runspace for the HTTP server $runspace = [runspacefactory]::CreateRunspace() $runspace.Name = 'ZtProgressServer' $runspace.Open() $ps = [powershell]::Create() $ps.Runspace = $runspace $null = $ps.AddScript({ param( [System.Collections.Concurrent.ConcurrentDictionary[string, object]]$ProgressDict, [string]$HtmlContent, [int]$Port ) $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add("http://localhost:$Port/") try { $listener.Start() while ($listener.IsListening) { try { # Use GetContextAsync with a short wait to allow checking IsListening $contextTask = $listener.GetContextAsync() while (-not $contextTask.AsyncWaitHandle.WaitOne(500)) { if (-not $listener.IsListening) { return } } $context = $contextTask.GetAwaiter().GetResult() } catch [System.Net.HttpListenerException] { # Listener was stopped break } catch [System.ObjectDisposedException] { break } catch { continue } try { $request = $context.Request $response = $context.Response $localPath = $request.Url.LocalPath # CORS headers $response.Headers.Add('Access-Control-Allow-Origin', '*') $response.Headers.Add('Cache-Control', 'no-cache, no-store, must-revalidate') if ($localPath -eq '/api/progress') { # Build JSON snapshot from the ConcurrentDictionary $response.ContentType = 'application/json; charset=utf-8' # Read stage metadata $stage = $null; $null = $ProgressDict.TryGetValue('_stage', [ref]$stage) $stageNumber = 0; $null = $ProgressDict.TryGetValue('_stageNumber', [ref]$stageNumber) $stageName = $null; $null = $ProgressDict.TryGetValue('_stageName', [ref]$stageName) $totalStages = 6; $null = $ProgressDict.TryGetValue('_totalStages', [ref]$totalStages) $startedAt = $null; $null = $ProgressDict.TryGetValue('_startedAt', [ref]$startedAt) $totalItems = 0; $null = $ProgressDict.TryGetValue('_totalItems', [ref]$totalItems) $completedItems = 0; $null = $ProgressDict.TryGetValue('_completedItems', [ref]$completedItems) $failedItems = 0; $null = $ProgressDict.TryGetValue('_failedItems', [ref]$failedItems) $inProgressItems = 0; $null = $ProgressDict.TryGetValue('_inProgressItems', [ref]$inProgressItems) # Build enriched stages array from definitions # _stageDefinitions is stored as a JSON string for cross-runspace safety. # Parse it here with ConvertFrom-Json so we get PSCustomObjects with reliable # .number and .name property access in this isolated runspace. $stageDefsJson = $null; $null = $ProgressDict.TryGetValue('_stageDefinitions', [ref]$stageDefsJson) $stagesArray = [System.Collections.Generic.List[object]]::new() if ($stageDefsJson) { $stageDefs = $stageDefsJson | ConvertFrom-Json foreach ($sd in $stageDefs) { $stageStatus = 'pending' $stageLabel = $sd.name if ($stage -eq 'done') { $stageStatus = 'completed' } elseif ($sd.number -lt $stageNumber) { $stageStatus = 'completed' } elseif ($sd.number -eq $stageNumber) { $stageStatus = 'active' $stageLabel = $stageName # Use the current sub-stage name (e.g. 'Importing Data into Database') } $stagesArray.Add(@{ number = $sd.number name = $stageLabel status = $stageStatus }) } } # Collect workers - use .Keys and TryGetValue for cross-runspace safety $workers = [System.Collections.Generic.List[object]]::new() foreach ($key in @($ProgressDict.Keys)) { if ($key.StartsWith('worker_')) { $w = $null if ($ProgressDict.TryGetValue($key, [ref]$w) -and $null -ne $w) { $workers.Add(@{ id = $w.Id name = $w.Name status = $w.Status detail = $w.Detail startedAt = $w.StartedAt updatedAt = $w.UpdatedAt }) } } } $percent = 0 if ($totalItems -gt 0) { $percent = [math]::Min(100, [math]::Floor(($completedItems + $failedItems) / $totalItems * 100)) } $snapshot = @{ stage = $stage stageNumber = $stageNumber totalStages = $totalStages stageName = $stageName startedAt = $startedAt percent = $percent stages = @($stagesArray) summary = @{ total = $totalItems completed = $completedItems failed = $failedItems inProgress = $inProgressItems pending = [math]::Max(0, $totalItems - $completedItems - $failedItems - $inProgressItems) } workers = @($workers) } $json = $snapshot | ConvertTo-Json -Depth 5 -Compress $buffer = [System.Text.Encoding]::UTF8.GetBytes($json) $response.ContentLength64 = $buffer.Length $response.OutputStream.Write($buffer, 0, $buffer.Length) } elseif ($localPath -eq '/' -or $localPath -eq '/index.html') { # Serve the progress dashboard HTML $response.ContentType = 'text/html; charset=utf-8' $buffer = [System.Text.Encoding]::UTF8.GetBytes($HtmlContent) $response.ContentLength64 = $buffer.Length $response.OutputStream.Write($buffer, 0, $buffer.Length) } else { $response.StatusCode = 404 $buffer = [System.Text.Encoding]::UTF8.GetBytes('Not Found') $response.ContentLength64 = $buffer.Length $response.OutputStream.Write($buffer, 0, $buffer.Length) } } catch { # Best effort: don't crash the server on a single bad request } finally { try { $response.OutputStream.Close() } catch { } } } } finally { # Ensure the port is released regardless of how the server exits — # normal loop exit, exception, or forced PowerShell.Stop() (PipelineStoppedException). try { $listener.Stop() } catch { } try { $listener.Close() } catch { } } }).AddArgument($progressDict).AddArgument($htmlContent).AddArgument($selectedPort) # Start the server asynchronously $asyncResult = $ps.BeginInvoke() # Store server state for later cleanup $script:__ZtSession.ProgressServer = @{ PowerShell = $ps Runspace = $runspace AsyncResult = $asyncResult Port = $selectedPort Url = $url } Write-Host Write-Host "Progress dashboard: " -NoNewline -ForegroundColor White Write-Host $url -ForegroundColor Cyan Write-Host # Auto-open the progress dashboard in the default browser Start-Process $url } } |