Three commands turn long-running work into a live view on an interactive console and into plain lines everywhere else: ForEach-ConsoleItem (Invoke-ConsoleRun) for many items, Watch-Console for a command you want to see change, and Invoke-ConsolePester for tests. Each writes ordinary PowerShell output when it ends, so it fits into a script like the commands it replaces.
Forty servers, eight at a time
ForEach-ConsoleItem runs a script block for every item, here in parallel with a time limit. Three servers fail and one hangs; the run keeps going, and the failures are listed when it ends.
Patching the web farm ━━━━━━━━━━────────── 20/40 50% ~4s left · ✗ 1 failed ✓ 19 passed ● 8 running ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✗ ✓ ✓ ✓ ✓ ✓ ● ✓ ✓ ● ● ● ● ● ● ● ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ⠹ #19 web19 1.3s ⠴ #22 web22 953ms ⠦ #23 web23 733ms ⠧ #24 web24 682ms ⠇ #25 web25 593ms ⠏ #26 web26 530ms ⠋ #27 web27 343ms ⠙ #28 web28 186ms ✗ web13: web13 did not come back after the reboot
# Forty servers patched eight at a time. Three fail and one hangs past the four-second limit;
# the view lists the failures and -ErrorVariable keeps them for the rest of the script.
$servers = 1..40 | ForEach-Object { 'web{0:d2}' -f $_ }
$results = $servers | ForEach-ConsoleItem -Parallel -ThrottleLimit 8 -TimeoutSeconds 4 -Title 'Patching the web farm' -ErrorVariable failed -ErrorAction SilentlyContinue {
$number = [int] $_.Substring(3)
# … 3 more lines in the full program
"$_ patched"
}Full program
# Forty servers patched eight at a time. Three fail and one hangs past the four-second limit;
# the view lists the failures and -ErrorVariable keeps them for the rest of the script.
$servers = 1..40 | ForEach-Object { 'web{0:d2}' -f $_ }
$results = $servers | ForEach-ConsoleItem -Parallel -ThrottleLimit 8 -TimeoutSeconds 4 -Title 'Patching the web farm' -ErrorVariable failed -ErrorAction SilentlyContinue {
$number = [int] $_.Substring(3)
Start-Sleep -Milliseconds (500 + ($number * 137) % 900)
if ($number % 13 -eq 0) { throw "$_ did not come back after the reboot" }
if ($number -eq 22) { Start-Sleep -Seconds 10 }
"$_ patched"
}IEnumerable<string> servers = Enumerable.Range(1, 40).Select(n => "web" + n.ToString("00", CultureInfo.InvariantCulture));
IReadOnlyList<RunResult<string, string>> results = await ConsoleRun.ForEachAsync(servers, async (server, cancel) => {
int number = int.Parse(server.Substring(3), CultureInfo.InvariantCulture);
// … 7 more lines in the full program
return server + " patched";
}, new RunOptions { ThrottleLimit = 8, Timeout = TimeSpan.FromSeconds(4), Title = "Patching the web farm" });Full program
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ConsoleMonster.Live;
namespace ConsoleMonster.Examples.Showcases {
/// <summary>
/// Forty servers patched eight at a time with <see cref="ConsoleRun.ForEachAsync{TItem, TResult}"/>: the live view shows
/// the summary bar, one cell per server, the running items and the failures. Three servers fail and one runs past the
/// four-second limit. The C# twin of the site's <c>Website/examples/demos/parallel.ps1</c>; the data is invented.
/// </summary>
public static class ParallelPatch {
/// <summary>Runs the patch wave and returns the number of failed servers.</summary>
/// <returns>The failures.</returns>
public static async Task<int> RunAsync() {
// <example>
IEnumerable<string> servers = Enumerable.Range(1, 40).Select(n => "web" + n.ToString("00", CultureInfo.InvariantCulture));
IReadOnlyList<RunResult<string, string>> results = await ConsoleRun.ForEachAsync(servers, async (server, cancel) => {
int number = int.Parse(server.Substring(3), CultureInfo.InvariantCulture);
await Task.Delay(500 + number * 137 % 900, cancel);
if (number % 13 == 0) {
throw new InvalidOperationException(server + " did not come back after the reboot");
}
if (number == 22) {
await Task.Delay(TimeSpan.FromSeconds(10), cancel);
}
return server + " patched";
}, new RunOptions { ThrottleLimit = 8, Timeout = TimeSpan.FromSeconds(4), Title = "Patching the web farm" });
// </example>
return results.Count(r => !r.Succeeded);
}
}
}Use it in your script
Install-Module ConsoleMonster -Scope CurrentUserReference: Invoke-ConsoleRun, ConsoleRun (C#)
What the run view shows
- A summary bar: done of total, the percentage, the time left (estimated from the time so far), and counters for passed, failed, skipped and running items. Counters keep their order, and zeros are dimmed rather than hidden, so the line does not jump.
- A cell map: one symbol per item in its status colour, so a run of hundreds of items still fits on a few rows.
- The items running now, with how long each has been running.
- The failures, listed with their messages when the run ends. An item that runs past
-TimeoutSecondsis stopped and counted as failed.
Without -Parallel the items run one at a time in the caller's scope, as with ForEach-Object. With it, up to -ThrottleLimit items run at a time in their own runspaces on Windows PowerShell 5.1 and PowerShell 7; pass the caller's variables with $using:name. In redirected output, CI and jobs, the view becomes one line per finished item and a summary.
The summary bar and the cell map are widgets of their own, for your own views:
$states = @('Pass') * 28 + @('Fail') * 2 + @('Warn') + @('Running') * 4
New-ConsoleRows -Gap 1 {
New-ConsoleSummaryBar -Done 31 -Total 40 -Elapsed 42s -Counter ([ordered] @{ passed = 28; failed = 2; warnings = 1 })
New-ConsoleCellMap -Status $states -Count 40
} | Write-ConsoleWidget
In C#, ConsoleRun.ForEachAsync runs the same view around any async work, and SummaryBar and CellMap are in ConsoleMonster.Widgets.
A command as a live view
Watch-Console runs a script block every few seconds and shows its latest output; -Differences marks what changed since the last run.
Every 1.5s: Mail queues #4 · 09:56:00 ╭─────────────────┬────────┬──────────┬───────────┬────────╮ │ Server │ Queued │ Deferred │ Delivered │ Status │ ├─────────────────┼────────┼──────────┼───────────┼────────┤ │ MX01.evotec.xyz │ 24 │ 0 │ 1,594 │ ✓ Pass │ │ MX02.evotec.xyz │ 36 │ 0 │ 1,621 │ ✓ Pass │ │ MX03.evotec.xyz │ 4 │ 32 │ 1,549 │ ▴ Warn │ │ MX04.evotec.xyz │ 16 │ 1 │ 1,576 │ ✓ Pass │ ╰─────────────────┴────────┴──────────┴───────────┴────────╯
# The mail queues every 1.5 seconds; -Differences marks the cells that changed since the last run.
$script:tick = 0
Watch-Console -Title 'Mail queues' -Interval 1.5 -Count 6 -Differences {
$script:tick++
$servers = 'MX01', 'MX02', 'MX03', 'MX04'
for ($i = 0; $i -lt $servers.Count; $i++) {
$load = ($script:tick * 7 + $i * 3) % 11
[pscustomobject] @{
Server = "$($servers[$i]).evotec.xyz"
Queued = $load * 4
Deferred = if ($i -eq 2) { 12 + $script:tick * 5 } else { $load % 3 }
Delivered = 1200 + $script:tick * 85 + $load * 9
Status = if ($i -eq 2 -and $script:tick -gt 2) { 'Warn' } else { 'Pass' }
}
}
}Use it in your script
Install-Module ConsoleMonster -Scope CurrentUserReference: Watch-Console
Watch-Console runs the script block every -Interval seconds and replaces the view with its latest output, shaped as Format-ConsoleTable shapes it. -Differences marks the cells that changed since the previous run, a run that fails shows its error and the watch goes on, and -Count ends it after that many runs. Ctrl+C ends it and leaves the last frame in the scrollback. Piped on, or without an interactive console, every run is printed instead.
Pester tests as they run
Invoke-ConsolePester takes Pester's parameters and shows the current test, a cell per test and each failure with its expected and actual values.
Mail domain checks ● running · 00:00:03 ━━━━━━━━━━━━━━━━━─── 12/14 85% ~1s left · ✗ 2 failed ✓ 9 passed – 1 skipped ╭─ Files ─────────────────────────────────────────────────────────────────────── 0 of 1 done ─╮ │ │ │ ⠸ MailDomain.Tests.ps1 ━━━━━━━━━━━━━━━━━━━─── 85% ✓ 9 ✗ 2 – 1 serves the policy file │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✗ ✓ ✓ ✗ – ● ○ ⠼ evotec.xyz › MTA-STS › serves the policy file 61ms ╭─ Failures ────────────────────────────────────────────────────────────────────────────── 2 ─╮ │ │ │ ✗ evotec.xyz › DMARC › enforces a reject policy │ │ expected reject │ │ actual none │ │ MailDomain.Tests.ps1:15 │ │ │ │ ✗ evotec.xyz › DKIM › uses 2048-bit keys │ │ expected the actual value to be greater than or equal to 2048 │ │ actual 1024 │ │ MailDomain.Tests.ps1:20 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ctrl+c stop
# Pester 5 tests with a live view: the current test, counts so far and the failures as they happen.
Invoke-ConsolePester -Path ./fixtures -Title 'Mail domain checks'Use it in your tests
Install-Module ConsoleMonster -Scope CurrentUser
Install-Module Pester -Scope CurrentUser -SkipPublisherCheckReference: Invoke-ConsolePester
Invoke-ConsolePester takes Pester's own parameters (-Path, -TagFilter, -Configuration...) and shows the run as it happens: a file list with progress, one cell per test, the test running now and each failure with its expected and actual values. It returns Pester's result object with -PassThru. When the run ends, a table of results by file, the failures and the totals are written; in redirected output and CI they are plain text.