Skip to content
ConsoleMonster

Server toolbox

Select servers and maintenance tasks, review options and follow a run into actionable results.

Demos Terminal apps Showcase

Use it when operators need to plan, run and review a repeatable server maintenance workflow.

  • Live
  • 128×40
  • PowerShell 7.4+
  • .NET 8.0+
Server toolbox 128×4024-bitFull

How it's built

The whole program in one file, with its sample data at the top. It drew the recording above; both languages draw the same cells.

PowerShelltoolbox.ps1View on GitHub
# Server toolbox: menus, selections, review, live work and actionable results.
# Synthetic data and timings; reports and notifications stay in memory. No remote effects.
[CmdletBinding()]
param([switch] $StaticSnapshot, [ValidateRange(0, 5)] [int] $Step = 0)
Import-Module ConsoleMonster
$ErrorActionPreference = 'Stop'

# Data: replace these inventories, operations and findings with your own sources.
$inventory = @'
Name|Domain|Site|Role|Detail|Offline
FS01|lab.example.test|Warsaw|File|Windows Server 2022|
FS02|lab.example.test|Warsaw|File|Windows Server 2022|
WEB01|lab.example.test|Warsaw|Web|Windows Server 2025|
SQL01|lab.example.test|Warsaw|Database|Windows Server 2019|
FS11|lab.example.test|Berlin|File|Windows Server 2022|
WEB11|lab.example.test|Berlin|Web|Windows Server 2025|
PRN11|lab.example.test|Berlin|Print|Windows Server 2016|offline since 09:12
BKP21|lab.example.test|Dublin|Backup|Windows Server 2022|
SQL21|lab.example.test|Dublin|Database|Windows Server 2022|
LAB01|lab.example.test|Lab|Test|Windows Server 2025|
LAB02|lab.example.test|Lab|Test|Windows Server 2025|maintenance until Friday
'@ | ConvertFrom-Csv -Delimiter '|'
$catalog = @'
Id|Name|Category|Description|Expected|Minutes|Changes
DiskCleanup|Disk cleanup|Maintenance|Removes temporary files and old update caches.|cache under 1 GB|3|True
LogRotation|Log rotation|Maintenance|Compresses logs older than 7 days.|retention 90 days|2|True
RestartPending|Pending restart check|Maintenance|Reports restarts waiting after updates.|no restart pending|1|False
PatchStatus|Patch status|Security|Lists missing security updates.|0 missing|2|False
Certificates|Certificate expiry|Security|Finds certificates expiring within 30 days.|30+ days|1|False
LocalAdmins|Local administrators audit|Security|Compares local Administrators with the approved list.|approved list|1|False
Firewall|Firewall rules review|Security|Flags inbound allow rules open to any address.|scoped rules|2|False
EventErrors|Event log errors|Health|Summarizes errors from the last 24 hours.|under 20|1|False
Services|Service health|Health|Checks and restarts stopped automatic services.|all running|1|True
DiskSpace|Disk space|Health|Warns below 15% and fails below 5% free.|15% free|1|False
Uptime|Uptime and reboots|Health|Reports unexpected reboots in the last 30 days.|0 unexpected|1|False
'@ | ConvertFrom-Csv -Delimiter '|' | ForEach-Object {
    $_.Minutes = [double]::Parse($_.Minutes, [cultureinfo]::InvariantCulture)
    $_.Changes = $_.Changes -eq 'True'; $_
}
$findings = @{
    'FS02/PatchStatus' = @('Fail', '3 updates missing for 41 days')
    'WEB01/Certificates' = @('Warn', 'expires in 24 days')
    'SQL01/DiskSpace' = @('Fail', '4% free on data volume')
    'FS11/Services' = @('Warn', '1 service restarted')
    'FS01/DiskCleanup' = @('Warn', '1.4 GB remains')
}
$history = @(
    [pscustomobject] @{ When = 'Mon 19 Oct 21:04'; Servers = '3'; Tasks = '4'; Result = '10 passed · 1 warn · 1 failed'; Duration = '00:06:12' }
    [pscustomobject] @{ When = 'Tue 20 Oct 07:30'; Servers = '2'; Tasks = '3'; Result = '6 passed · 0 warn · 0 failed'; Duration = '00:01:06' }
)
$reportRoot = if ($IsWindows) { 'C:\Reports\Toolbox' } else { '/tmp/reports/toolbox' }
$plan = @{
    Domain = 'lab.example.test'; Targets = @($inventory | Where-Object { $_.Domain -eq 'lab.example.test' -and -not $_.Offline } | Select-Object -First 4)
    Checks = @($catalog | Where-Object Id -In @('DiskCleanup', 'PatchStatus', 'Certificates', 'DiskSpace'))
    History = $history; CompletedWork = @(); Parallel = 3; Report = $reportRoot; Format = 'Html'; Notify = $false; Email = '[email protected]'; DryRun = $false
}
$StaticSnapshot = $StaticSnapshot -or [Console]::IsInputRedirected

function Get-Work {
    for ($ci = 0; $ci -lt $plan.Checks.Count; $ci++) {
        for ($ni = 0; $ni -lt $plan.Targets.Count; $ni++) {
            $node = $plan.Targets[$ni]; $check = $plan.Checks[$ci]
            $outcome = $findings["$($node.Name)/$($check.Id)"]
            [pscustomobject] @{
                Node = $node; Check = $check
                Status = $(if ($outcome) { $outcome[0] } else { 'Pass' })
                Actual = $(if ($outcome) { $outcome[1] } else { $check.Expected })
            }
        }
    }
}
function Get-Verdict($Work) {
    if (@($Work | Where-Object Status -EQ Fail).Count) { 'Fail' }
    elseif (@($Work | Where-Object Status -EQ Warn).Count) { 'Warn' } else { 'Pass' }
}
function Get-Findings($Work) {
    $rows = @($Work | Where-Object Status -NE Pass | ForEach-Object {
        [pscustomobject] @{ Server = $_.Node.Name; Task = $_.Check.Name; Expected = $_.Check.Expected
            Actual = New-ConsoleText $_.Actual -Role $_.Status }
    })
    # Explicit text presenters keep the automatic PowerShell value shaping aligned with C#.
    $columns = @('Server', 'Task', 'Expected' | ForEach-Object { @{ Name = $_; Presenter = 'Text' } })
    $columns += @{ Name = 'Actual'; Flexible = $true }
    if ($rows.Count) { $rows | Format-ConsoleTable -Property $columns -Border Minimal -NoSort -AsWidget }
    else { New-ConsoleLiveTable -Name findings -Column $columns -Border Minimal }
}
function Get-History {
    $rows = @(for ($index = $plan.History.Count - 1; $index -ge 0; $index--) { $plan.History[$index] })
    $columns = @('When', 'Servers', 'Tasks', 'Result', 'Duration' | ForEach-Object { @{ Name = $_; Presenter = 'Text' } })
    $rows | Format-ConsoleTable -Property $columns -Border Minimal -NoSort -AsWidget
}
function Get-Preview($Check) {
    New-ConsoleRows -Gap 1 {
        New-ConsoleText $Check.Description
        New-ConsoleKeyValue -Markup -Data ([ordered] @{
            Group = $Check.Category; Expected = $Check.Expected; Duration = "about $($Check.Minutes) min per server"
            Changes = $(if ($Check.Changes) { '[warn]yes[/]' } else { '[pass]no[/]' }); Parameter = $Check.Id
        })
    }
}

# Layout: snapshots and live work use this same widget tree.
function Get-RunBody($Work, [int] $Done, [int] $Phase) {
    $finished = @($Work | Select-Object -First $Done)
    $active = if ($Phase -eq 2) { @($Work | Select-Object -Skip $Done | Where-Object { $_.Check.Id -eq $Work[$Done].Check.Id } | Select-Object -First $plan.Parallel) } else { @() }
    $counter = [ordered] @{
        passed = @($finished | Where-Object Status -EQ Pass).Count
        warnings = @($finished | Where-Object Status -EQ Warn).Count
        failed = @($finished | Where-Object Status -EQ Fail).Count
    }
    New-ConsoleRows {
        New-ConsoleSummaryBar -Done $Done -Total $Work.Count -Counter $counter -Elapsed ([timespan]::FromSeconds($Done * .24 / $plan.Parallel))
        New-ConsolePhases {
            $names = @('Discover', 'Connect', 'Tasks', 'Report')
            for ($i = 0; $i -lt $names.Count; $i++) {
                $status = if ($i -lt $Phase) { if ($i -eq 2) { Get-Verdict $finished } else { 'Pass' } }
                    elseif ($i -eq $Phase) { 'Running' } else { 'Pending' }
                New-ConsoleStatusItem $names[$i] -Status $status
            }
        }
        $lanes = New-ConsoleLanes -Name lanes {
            foreach ($node in $plan.Targets) {
                $planned = @($Work | Where-Object { $_.Node.Name -eq $node.Name })
                $ready = @($finished | Where-Object { $_.Node.Name -eq $node.Name })
                $doing = @($active | Where-Object { $_.Node.Name -eq $node.Name })
                $status = if ($ready.Count -eq $planned.Count) { Get-Verdict $ready }
                    elseif ($doing.Count) { 'Running' } else { 'Pending' }
                $detail = if ($doing.Count) { $doing[0].Check.Name } elseif ($ready.Count) { $ready[-1].Check.Name } else { $null }
                New-ConsoleLane $node.Name -Label (New-ConsoleText "$($node.Name)  $($node.Site)") -Total $planned.Count `
                    -Done $ready.Count -Status $status -Detail $detail -Passed @($ready | Where-Object Status -EQ Pass).Count `
                    -Warned @($ready | Where-Object Status -EQ Warn).Count -Failed @($ready | Where-Object Status -EQ Fail).Count
            }
        }
        New-ConsoleColumns -CollapseBelow 90 {
            New-ConsolePanel -Title Servers { $lanes }
            New-ConsolePanel -Title 'Run options' {
                New-ConsoleKeyValue -Data ([ordered] @{
                    Targets = "$($plan.Targets.Count) servers"; Tasks = "$($plan.Checks.Count) per server"
                    Parallel = [string] $plan.Parallel; Report = $plan.Format.ToUpperInvariant(); 'Dry run' = $(if ($plan.DryRun) { 'yes' } else { 'no' })
                })
            }
        }
        New-ConsolePanel -Title Findings { Get-Findings $finished }
        New-ConsolePanel -Title Log {
            New-ConsoleLog -NoTime -Height 7 {
                New-ConsoleLogEntry "Inventory: $($plan.Domain) · $($plan.Targets.Count) servers · $($Work.Count) tasks"
                if ($Phase -ge 1) { New-ConsoleLogEntry "Connecting up to $($plan.Parallel) servers at a time" }
                foreach ($w in @($finished | Select-Object -Last 5)) {
                    $level = if ($w.Status -eq 'Fail') { 'err' } elseif ($w.Status -eq 'Warn') { 'warn' } else { 'info' }
                    New-ConsoleLogEntry "$($w.Node.Name) · $($w.Check.Name): $($w.Actual)" -Level $level
                }
                if ($Phase -ge 3) { New-ConsoleLogEntry "Preparing $($plan.Format.ToUpperInvariant()) report: $($plan.Report)" }
                if ($Phase -ge 4) {
                    $text = if ($plan.Notify) { "Report ready · recipient $($plan.Email)" }
                        else { "Report ready · $(@($finished | Where-Object Status -NE Pass).Count) findings to review" }
                    New-ConsoleLogEntry $text
                }
            }
        }
    }
}
function Get-Dashboard($Work, [int] $Done, [int] $Phase) {
    New-ConsoleDashboard -Title '[bold]Server toolbox[/]' `
        -Subtitle "$($plan.Domain) · $($plan.Targets.Count) servers · $($plan.Checks.Count) tasks" `
        -Footer 'Ctrl+C cancel · results keep the findings' { Get-RunBody $Work $Done $Phase }
}

# Behaviour: native form/menu navigation, then a bounded run with cancellation and history.
function Invoke-Work($Context, $Work) {
    $plan.CompletedWork = @($Work)
    $dashboard = New-ConsoleDashboard -Title '[bold]Server toolbox[/]' -Subtitle $plan.Domain `
        -Footer 'Ctrl+C cancel · results keep the findings' {
        New-ConsoleRegion -Name body { Get-RunBody $Work 0 0 }
    }
    $runState = @{ Finished = $false }
    $outcome = $Context.Run($dashboard, {
        param($Run)
        $phase = 0; $done = 0
        foreach ($phase in 0, 1) {
            Set-ConsoleRegion -Name body { Get-RunBody $Work 0 $phase }
            Start-Sleep -Milliseconds 240
            if ($Run.CancellationToken.IsCancellationRequested) {
                return New-ConsoleRunResult 'Run cancelled' -Status Warn -Summary "$done of $($Work.Count) operations completed."
            }
        }
        foreach ($group in @($Work | Group-Object { $_.Check.Id })) {
            for ($offset = 0; $offset -lt $group.Count; $offset += $plan.Parallel) {
                Set-ConsoleRegion -Name body { Get-RunBody $Work $done 2 }
                Start-Sleep -Milliseconds 240
                if ($Run.CancellationToken.IsCancellationRequested) {
                    return New-ConsoleRunResult 'Run cancelled' -Status Warn -Summary "$done of $($Work.Count) operations completed."
                }
                $done += [math]::Min($plan.Parallel, $group.Count - $offset)
            }
        }
        foreach ($phase in 3, 4) {
            Set-ConsoleRegion -Name body { Get-RunBody $Work $done $phase }
            Start-Sleep -Milliseconds 240
            if ($Run.CancellationToken.IsCancellationRequested) {
                return New-ConsoleRunResult 'Run cancelled' -Status Warn -Summary "$done of $($Work.Count) operations completed."
            }
        }
        $result = Get-Result $Work
        $runState.Finished = $true
        $result
    })
    if (-not $runState.Finished) { return }
    $plan.History += [pscustomobject] @{
        When = 'Tue 20 Oct 09:30'; Duration = '{0:hh\:mm\:ss}' -f $outcome.Elapsed; Servers = [string] @($Work.Node.Name | Select-Object -Unique).Count
        Tasks = [string] @($Work.Check.Id | Select-Object -Unique).Count
        Result = "$(@($Work | Where-Object Status -EQ Pass).Count) passed · $(@($Work | Where-Object Status -EQ Warn).Count) warn · $(@($Work | Where-Object Status -EQ Fail).Count) failed"
    }
}
function Get-Result($Work) {
    $summary = "$($Work.Count) tasks on $(@($Work.Node.Name | Select-Object -Unique).Count) servers. Review the findings and choose the next action."
    # Retain the current run for next actions on the menu's owning thread.
    $next = [ordered] @{
        'View report' = {
            $MenuContext.ShowResult((New-ConsoleRunResult "Report · $($plan.Format.ToUpperInvariant())" `
                -Status (Get-Verdict $plan.CompletedWork) -Summary $plan.Report -Details (Get-Findings $plan.CompletedWork)))
        }
    }
    if (@($Work | Where-Object Status -EQ Fail).Count) {
        $next['Rerun failed tasks'] = { Invoke-Work $MenuContext @($plan.CompletedWork | Where-Object Status -EQ Fail) }
    }
    $next['View history'] = { $MenuContext.ShowResult((New-ConsoleRunResult 'Run history' -Details (Get-History))) }
    New-ConsoleRunResult 'Run finished' -Status (Get-Verdict $Work) -Summary $summary -Details (Get-Findings $Work) `
        -Figure ([ordered] @{ passed = @($Work | Where-Object Status -EQ Pass).Count
            warnings = @($Work | Where-Object Status -EQ Warn).Count; failed = @($Work | Where-Object Status -EQ Fail).Count }) `
        -FigureStatus @{ passed = 'Pass'; warnings = $(if (@($Work | Where-Object Status -EQ Warn).Count) { 'Warn' } else { 'Pass' })
            failed = $(if (@($Work | Where-Object Status -EQ Fail).Count) { 'Fail' } else { 'Pass' }) } -Next $next
}
function Get-OptionFields([string] $Page) {
    $pageOption = @{}
    if ($Page) { $pageOption.Page = $Page }
    New-ConsoleField @pageOption Parallel 'Servers at a time' -Type Number -Integer -Minimum 1 -Maximum 16 -Default 3 -Parameter ThrottleLimit
    New-ConsoleField @pageOption Report 'Report folder' -Type Path -Kind Folder -Default $reportRoot -Parameter ReportPath
    New-ConsoleField @pageOption Format 'Report format' -Type Select -Choices @(
        [pscustomobject] @{ Id = 'Html'; Name = 'HTML' }; [pscustomobject] @{ Id = 'Csv'; Name = 'CSV' }; [pscustomobject] @{ Id = 'Json'; Name = 'JSON' }
    ) -Display Name -CommandValue Id -Default Html -Parameter Format
    New-ConsoleField @pageOption Notify 'Email the report' -Type Toggle -Parameter Notify
    New-ConsoleField @pageOption Email Recipient -Default '[email protected]' -Parameter To -Required `
        -VisibleWhen { $Values.Notify } -Validate { if ($_ -notlike '*@*') { 'Enter an email address' } }
    New-ConsoleField @pageOption DryRun 'Dry run' -Type Toggle -Parameter WhatIf
}
function Set-Options($Values) {
    foreach ($name in 'Parallel', 'Report', 'Format', 'Notify', 'Email', 'DryRun') { $plan[$name] = $Values[$name] }
}
$options = New-ConsoleForm Options -NoReview -SubmitText Save { Get-OptionFields }
$targetItem = New-ConsoleMenuItem Targets -Hotkey t -IconName Targets -Description 'Inventory grouped by site; unavailable servers stay visible.' `
    -LoadChoices {
        param($Values, $Inventory)
        Start-Sleep -Milliseconds 700
        $Inventory
    } -ArgumentList (, $inventory) -Display Name -GroupBy Site -Describe Detail `
    -Selected $plan.Targets -Minimum 1 -ChoiceBadge Role -ChoiceDisabled Offline -Preview {
        New-ConsoleKeyValue -Data ([ordered] @{ Server = $_.Name; Site = $_.Site; Role = $_.Role; OS = $_.Detail })
    }
$taskItem = New-ConsoleMenuItem Tasks -Hotkey k -IconName Tasks -MultiSelect $catalog -Display Name -GroupBy Category `
    -Describe Description -Selected $plan.Checks -Minimum 1 -Preview { Get-Preview $_ } -ChoiceBadge {
        $badge = $(if ($_.Changes) { 'changes · ' }) + "~$($_.Minutes) min"
        New-ConsoleText $badge -Role $(if ($_.Changes) { 'Warn' } else { 'Muted' })
    }
$menu = New-ConsoleMenu 'Server toolbox' -Layout Shell -Subtitle 'Tue 20 Oct 2026 · 09:30' {
    $targetItem; $taskItem
    New-ConsoleMenuItem Options -Hotkey o -IconName Settings -Form $options -OnSubmit { Set-Options $Values }
    New-ConsoleMenuItem Presets -Hotkey p -IconName Favorite -Presets
    New-ConsoleMenuPreset 'Morning · Warsaw' -Form $options -Values @{ Parallel = 2; DryRun = $true } -Selections @{
        $targetItem = @($inventory | Where-Object Site -EQ Warsaw)
        $taskItem = @($catalog | Where-Object Id -In 'PatchStatus', 'Certificates', 'DiskSpace')
    } -Preview { New-ConsoleKeyValue -Data ([ordered] @{ Scope = 'Warsaw servers'; Tasks = 'Security and disk space'; 'Dry run' = 'yes' }) }
    New-ConsoleMenuPreset 'Health · all sites' -Selections @{
        $targetItem = @($inventory | Where-Object { -not $_.Offline })
        $taskItem = @($catalog | Where-Object Id -In 'EventErrors', 'Services', 'DiskSpace', 'Uptime')
    }
    New-ConsoleMenuItem -Separator
    New-ConsoleMenuItem 'Review & run' -Hotkey r -IconName Run -Review {
        $plan.Targets = @($targetItem.Selection | ForEach-Object { $_ })
        $plan.Checks = @($taskItem.Selection | ForEach-Object { $_ })
        $values = $options.GetValues()
        Set-Options $values
        $problems = @(
            if (-not $plan.Targets.Count) { 'Pick at least one target' }
            if (-not $plan.Checks.Count) { 'Pick at least one task' }
            if ($plan.Notify -and $plan.Email -notlike '*@*') { 'Enter the email recipient in Options' }
        )
        # Quote selected identifiers as PowerShell literals; options are produced by the native form.
        $names = @($plan.Targets.Name | ForEach-Object { "'$($_.Replace("'", "''"))'" }) -join ', '
        $ids = @($plan.Checks.Id | ForEach-Object { "'$($_.Replace("'", "''"))'" }) -join ', '
        @{
            Title = 'Review & run'; Description = 'Everything the run uses; the command carries the same options.'
            Rows = [ordered] @{ Targets = $plan.Targets.Name -join ', '; Tasks = $plan.Checks.Name -join ', '
                'Servers at a time' = [string] $plan.Parallel; Report = "$($plan.Report) ($($plan.Format.ToUpperInvariant()))"
                Email = $(if ($plan.Notify) { $plan.Email } else { 'off' }); 'Dry run' = $(if ($plan.DryRun) { 'yes' } else { 'no' }) }
            Problems = $problems; Command = "Invoke-ServerToolbox -ComputerName $names -Task $ids $($options.BuildCommand())"
            Run = { Invoke-Work $MenuContext @(Get-Work) }
        }
    }
    New-ConsoleMenuItem History -Hotkey h -IconName List -State { "$($plan.History.Count) runs" } -Page { Get-History }
    New-ConsoleMenuItem -Separator
    New-ConsoleMenuItem Quit -Exit
}
if ($StaticSnapshot) {
    $work = @(Get-Work)
    $done = if ($Step -lt 2) { 0 } elseif ($Step -eq 2) { [math]::Min($work.Count, $plan.Targets.Count + 2) }
        elseif ($Step -eq 3) { [int] [math]::Floor($work.Count * 2 / 3) } else { $work.Count }
    $phase = if ($Step -lt 2) { $Step } elseif ($Step -lt 4) { 2 } elseif ($Step -eq 4) { 3 } else { 4 }
    Get-Dashboard $work $done $phase | Write-ConsoleWidget
} else { Show-ConsoleMenu $menu -Layout Shell -AppName 'Server toolbox gallery' }
C#Program.csView on GitHub
// Server toolbox: menus, selections, review, live work and actionable results.
// Synthetic data and timings; reports and notifications stay in memory. No remote effects.
using ConsoleMonster;
using ConsoleMonster.Interactive;
using ConsoleMonster.Glyphs;
using ConsoleMonster.Layout;
using ConsoleMonster.Styling;
using ConsoleMonster.Text;
using ConsoleMonster.Widgets;

// Data: replace these inventories, operations and findings with your own sources.
Node[] inventory = {
    new("FS01", "lab.example.test", "Warsaw", "File", "Windows Server 2022", ""),
    new("FS02", "lab.example.test", "Warsaw", "File", "Windows Server 2022", ""),
    new("WEB01", "lab.example.test", "Warsaw", "Web", "Windows Server 2025", ""),
    new("SQL01", "lab.example.test", "Warsaw", "Database", "Windows Server 2019", ""),
    new("FS11", "lab.example.test", "Berlin", "File", "Windows Server 2022", ""),
    new("WEB11", "lab.example.test", "Berlin", "Web", "Windows Server 2025", ""),
    new("PRN11", "lab.example.test", "Berlin", "Print", "Windows Server 2016", "offline since 09:12"),
    new("BKP21", "lab.example.test", "Dublin", "Backup", "Windows Server 2022", ""),
    new("SQL21", "lab.example.test", "Dublin", "Database", "Windows Server 2022", ""),
    new("LAB01", "lab.example.test", "Lab", "Test", "Windows Server 2025", ""),
    new("LAB02", "lab.example.test", "Lab", "Test", "Windows Server 2025", "maintenance until Friday"),
};
Check[] catalog = {
    new("DiskCleanup", "Disk cleanup", "Maintenance", "Removes temporary files and old update caches.", "cache under 1 GB", 3, true),
    new("LogRotation", "Log rotation", "Maintenance", "Compresses logs older than 7 days.", "retention 90 days", 2, true),
    new("RestartPending", "Pending restart check", "Maintenance", "Reports restarts waiting after updates.", "no restart pending", 1, false),
    new("PatchStatus", "Patch status", "Security", "Lists missing security updates.", "0 missing", 2, false),
    new("Certificates", "Certificate expiry", "Security", "Finds certificates expiring within 30 days.", "30+ days", 1, false),
    new("LocalAdmins", "Local administrators audit", "Security", "Compares local Administrators with the approved list.", "approved list", 1, false),
    new("Firewall", "Firewall rules review", "Security", "Flags inbound allow rules open to any address.", "scoped rules", 2, false),
    new("EventErrors", "Event log errors", "Health", "Summarizes errors from the last 24 hours.", "under 20", 1, false),
    new("Services", "Service health", "Health", "Checks and restarts stopped automatic services.", "all running", 1, true),
    new("DiskSpace", "Disk space", "Health", "Warns below 15% and fails below 5% free.", "15% free", 1, false),
    new("Uptime", "Uptime and reboots", "Health", "Reports unexpected reboots in the last 30 days.", "0 unexpected", 1, false),
};
Dictionary<string, (StatusKind Status, string Actual)> findings = new() {
    ["FS02/PatchStatus"] = (StatusKind.Fail, "3 updates missing for 41 days"),
    ["WEB01/Certificates"] = (StatusKind.Warn, "expires in 24 days"),
    ["SQL01/DiskSpace"] = (StatusKind.Fail, "4% free on data volume"),
    ["FS11/Services"] = (StatusKind.Warn, "1 service restarted"),
    ["FS01/DiskCleanup"] = (StatusKind.Warn, "1.4 GB remains"),
};
List<HistoryRow> history = new() {
    new("Mon 19 Oct 21:04", 3, 4, 10, 1, 1, TimeSpan.FromSeconds(372)), new("Tue 20 Oct 07:30", 2, 3, 6, 0, 0, TimeSpan.FromSeconds(66))
};
string reportRoot = OperatingSystem.IsWindows() ? @"C:\Reports\Toolbox" : "/tmp/reports/toolbox";
Node[] targets = inventory.Where(n => n.Domain == "lab.example.test" && n.Offline == "").Take(4).ToArray();
Check[] checks = catalog.Where(c => new[] { "DiskCleanup", "PatchStatus", "Certificates", "DiskSpace" }.Contains(c.Id)).ToArray();
string domain = "lab.example.test", format = "Html", report = reportRoot, email = "[email protected]";
int parallel = 3;
bool notify = false, dryRun = false;
bool snapshot = args.Contains("--snapshot") || (!args.Contains("--interactive") && Console.IsInputRedirected);
int stepAt = Array.IndexOf(args, "--step");
int step = stepAt < 0 ? 0 : int.Parse(args[stepAt + 1], System.Globalization.CultureInfo.InvariantCulture);
if (step < 0 || step > 5) throw new ArgumentOutOfRangeException(nameof(step));

List<Work> Plan() => checks.SelectMany(c => targets.Select(n => {
    var outcome = findings.GetValueOrDefault(n.Name + "/" + c.Id, (StatusKind.Pass, c.Expected));
    return new Work(n, c, outcome.Item1, outcome.Item2);
})).ToList();
StatusKind Verdict(IEnumerable<Work> work) => work.Any(w => w.Status == StatusKind.Fail) ? StatusKind.Fail
    : work.Any(w => w.Status == StatusKind.Warn) ? StatusKind.Warn : StatusKind.Pass;
Table TableOf(string[] headers) {
    Table table = new() { Border = TableBorder.Minimal, RecordViewRows = 0 };
    foreach (string header in headers) table.AddColumn(header, HorizontalAlignment.Left);
    return table;
}
IWidget Findings(IEnumerable<Work> work) {
    Table table = TableOf(new[] { "Server", "Task", "Expected", "Actual" });
    table.Columns[3].Flexible = true;
    foreach (Work w in work.Where(w => w.Status != StatusKind.Pass))
        table.AddRow(w.Node.Name, w.Check.Name, w.Check.Expected, TableCell.FromValue(RichText.Plain(w.Actual, new RichStyle(w.Status == StatusKind.Fail ? ThemeToken.Fail : ThemeToken.Warn))));
    return table;
}
IWidget History() {
    Table table = TableOf(new[] { "When", "Servers", "Tasks", "Result", "Duration" });
    foreach (HistoryRow h in history.AsEnumerable().Reverse())
        table.AddRow(h.When, h.Servers.ToString(), h.Checks.ToString(),
            $"{h.Passed} passed · {h.Warnings} warn · {h.Failed} failed", h.Elapsed.ToString(@"hh\:mm\:ss"));
    return table;
}
IWidget Preview(Check c) => new Rows(new TextBlock(Markup.Escape(c.Description)), new KeyValueList()
    .Add("Group", c.Category).Add("Expected", c.Expected).Add("Duration", "about " + c.Minutes + " min per server")
    .Add("Changes", c.Changes ? "[warn]yes[/]" : "[pass]no[/]").Add("Parameter", c.Id)) { Gap = 1 };

// Layout: snapshots and live work use this same widget tree.
IWidget Body(List<Work> work, int done, int phase) {
    Work[] finished = work.Take(done).ToArray();
    Work[] active = phase == 2 ? work.Skip(done).TakeWhile(w => w.Check.Id == work[done].Check.Id).Take(parallel).ToArray() : Array.Empty<Work>();
    SummaryBar summary = new SummaryBar { Elapsed = TimeSpan.FromSeconds(done * .24 / parallel) }.SetProgress(done, work.Count)
        .SetCounter("passed", finished.Count(w => w.Status == StatusKind.Pass), StatusKind.Pass)
        .SetCounter("warnings", finished.Count(w => w.Status == StatusKind.Warn), StatusKind.Warn)
        .SetCounter("failed", finished.Count(w => w.Status == StatusKind.Fail), StatusKind.Fail);
    Phases phases = new();
    string[] names = { "Discover", "Connect", "Tasks", "Report" };
    for (int i = 0; i < names.Length; i++) phases.Set(names[i], i < phase ? (i == 2 ? Verdict(finished) : StatusKind.Pass) : i == phase ? StatusKind.Running : StatusKind.Pending);
    Lanes lanes = new("lanes");
    foreach (Node node in targets) {
        Work[] planned = work.Where(w => w.Node == node).ToArray();
        Work[] ready = finished.Where(w => w.Node == node).ToArray();
        Lane lane = lanes.Add(node.Name, RichText.Plain(node.Name + "  " + node.Site), planned.Length); lane.Done = ready.Length;
        lane.Passed = ready.Count(w => w.Status == StatusKind.Pass); lane.Warned = ready.Count(w => w.Status == StatusKind.Warn); lane.Failed = ready.Count(w => w.Status == StatusKind.Fail);
        lane.Status = ready.Length == planned.Length ? Verdict(ready) : active.Any(w => w.Node == node) ? StatusKind.Running : StatusKind.Pending;
        lane.Detail = active.FirstOrDefault(w => w.Node == node)?.Check.Name ?? ready.LastOrDefault()?.Check.Name;
    }
    LogRegion log = new() { ShowTime = false, PreferredHeight = 7 };
    log.Write($"Inventory: {domain} · {targets.Length} servers · {work.Count} tasks");
    if (phase >= 1) log.Write($"Connecting up to {parallel} servers at a time");
    foreach (Work w in finished.TakeLast(5)) log.Write($"{w.Node.Name} · {w.Check.Name}: {w.Actual}", w.Status == StatusKind.Fail ? "err" : w.Status == StatusKind.Warn ? "warn" : "info");
    if (phase >= 3) log.Write("Preparing " + format.ToUpperInvariant() + " report: " + report);
    if (phase >= 4) log.Write(notify ? "Report ready · recipient " + email : "Report ready · " + finished.Count(w => w.Status != StatusKind.Pass) + " findings to review");
    KeyValueList facts = new KeyValueList().Add("Targets", targets.Length + " servers").Add("Tasks", checks.Length + " per server")
        .Add("Parallel", parallel.ToString()).Add("Report", format.ToUpperInvariant()).Add("Dry run", dryRun ? "yes" : "no");
    Columns info = new(new Panel(lanes, "Servers"), new Panel(facts, "Run options")) { CollapseBelow = 90 };
    return new Rows(summary, phases, info, new Panel(Findings(finished), "Findings"), new Panel(log, "Log"));
}
Dashboard Dashboard(List<Work> work, int done, int phase) => new("[bold]Server toolbox[/]", Body(work, done, phase)) {
    Subtitle = $"{domain} · {targets.Length} servers · {checks.Length} tasks", Footer = "Ctrl+C cancel · results keep the findings"
};

// Behaviour: native form/menu navigation, then a bounded run with cancellation and history.
void Execute(MenuContext context, List<Work> work) {
    Slot body = new("body", Body(work, 0, 0));
    Dashboard dashboard = new("[bold]Server toolbox[/]", body) { Subtitle = domain, Footer = "Ctrl+C cancel · results keep the findings" };
    bool completed = false;
    RunResult outcome = context.Run(dashboard, run => {
        void Show(int done, int phase) { body.Handle(run.Session).Set(Body(work, done, phase)); if (run.CancellationToken.WaitHandle.WaitOne(240)) run.CancellationToken.ThrowIfCancellationRequested(); }
        Show(0, 0); Show(0, 1);
        int done = 0;
        foreach (var operation in work.GroupBy(w => w.Check.Id)) {
            foreach (Work[] batch in operation.Chunk(parallel)) { Show(done, 2); done += batch.Length; }
        }
        Show(done, 3); Show(done, 4);
        RunResult result = Result(work); completed = true; return result;
    });
    if (!completed) return;
    history.Add(new("Tue 20 Oct 09:30", work.Select(w => w.Node.Name).Distinct().Count(), work.Select(w => w.Check.Id).Distinct().Count(),
        work.Count(w => w.Status == StatusKind.Pass), work.Count(w => w.Status == StatusKind.Warn), work.Count(w => w.Status == StatusKind.Fail), outcome.Elapsed));
}
RunResult Result(List<Work> work) {
    RunResult result = new("Run finished", Verdict(work)) {
        Summary = $"{work.Count} tasks on {work.Select(w => w.Node.Name).Distinct().Count()} servers. Review the findings and choose the next action.", Details = Findings(work)
    };
    result.Figure("passed", work.Count(w => w.Status == StatusKind.Pass).ToString(), StatusKind.Pass);
    result.Figure("warnings", work.Count(w => w.Status == StatusKind.Warn).ToString(), work.Any(w => w.Status == StatusKind.Warn) ? StatusKind.Warn : StatusKind.Pass);
    result.Figure("failed", work.Count(w => w.Status == StatusKind.Fail).ToString(), work.Any(w => w.Status == StatusKind.Fail) ? StatusKind.Fail : StatusKind.Pass);
    result.Next("View report", ctx => ctx.ShowResult(new RunResult("Report · " + format.ToUpperInvariant(), Verdict(work)) { Summary = report, Details = Findings(work) }));
    if (work.Any(w => w.Status == StatusKind.Fail)) result.Next("Rerun failed tasks", ctx => Execute(ctx, work.Where(w => w.Status == StatusKind.Fail).ToList()));
    result.Next("View history", ctx => ctx.ShowResult(new RunResult("Run history") { Details = History() }));
    return result;
}
Form Options() {
    Form form = new("Options") { Review = false, SubmitText = "Save" };
    FormPage page = form.Page("Options", "Parallelism, reports and notifications.");
    NumberField throttle = page.Number("Parallel", "Servers at a time");
    throttle.Default = parallel; throttle.Minimum = 1; throttle.Maximum = 16; throttle.WholeNumber = true; throttle.Parameter = "ThrottleLimit";
    PathField folder = page.Path("Report", "Report folder"); folder.Default = report; folder.PathKind = PathKind.Folder; folder.Parameter = "ReportPath";
    ChoiceField output = page.Select("Format", "Report format", new[] { new Choice("Html", "HTML"), new Choice("Csv", "CSV"), new Choice("Json", "JSON") });
    output.Default = format; output.Parameter = "Format";
    ToggleField send = page.Toggle("Notify", "Email the report"); send.Parameter = "Notify";
    TextField recipient = page.Text("Email", "Recipient"); recipient.Default = email; recipient.Parameter = "To"; recipient.Required = true;
    recipient.VisibleWhen = v => v.Get<bool>("Notify"); recipient.Validator = (value, _) => value is string s && s.Contains('@') ? null : "Enter an email address";
    ToggleField dry = page.Toggle("DryRun", "Dry run"); dry.Default = false; dry.Parameter = "WhatIf";
    return form;
}
Form options = Options();
void ApplyOptions(FormValues values) {
    parallel = (int)values.Get<decimal>("Parallel", 3m); report = values.Get<string>("Report", reportRoot); format = values.Get<string>("Format", "Html");
    notify = values.Get<bool>("Notify"); email = values.Get<string>("Email", "[email protected]"); dryRun = values.Get<bool>("DryRun");
}
Menu menu = new("Server toolbox") { Layout = MenuLayout.Shell, Subtitle = "Tue 20 Oct 2026 · 09:30" };
MenuItem targetItem = new MenuItem("Targets").Hotkey('t').WithIcon(GlyphIcon.Targets).Describe("Inventory grouped by site; unavailable servers stay visible.")
    .LoadChoices(async (_, token) => {
        await Task.Delay(700, token);
        return Choice.From(inventory, n => n.Name, n => n.Site, description: n => n.Detail,
            badge: n => RichText.Plain(n.Role, new RichStyle(ThemeToken.Muted)), disabled: n => n.Offline);
    });
targetItem.SetSelection(targets.Cast<object?>());
targetItem.Minimum = 1;
targetItem.Preview = c => { Node n = (Node)c.Value!; return new KeyValueList().Add("Server", n.Name).Add("Site", n.Site).Add("Role", n.Role).Add("OS", n.Detail); };
MenuItem taskItem = new MenuItem("Tasks").Hotkey('k').WithIcon(GlyphIcon.Tasks).MultiSelect(catalog.Select(c => new Choice(c.Id, c.Name) {
    Group = c.Category, Description = c.Description, Badge = RichText.Plain((c.Changes ? "changes · " : "") + "~" + c.Minutes + " min", new RichStyle(c.Changes ? ThemeToken.Warn : ThemeToken.Muted)), Selected = checks.Contains(c)
}));
taskItem.Minimum = 1; taskItem.Preview = c => Preview(catalog.First(k => k.Id == (string)c.Value!));
menu.Add(targetItem); menu.Add(taskItem);
MenuItem optionItem = new MenuItem("Options").Hotkey('o').WithIcon(GlyphIcon.Settings).Form(options, (_, result) => ApplyOptions(result.Values)); menu.Add(optionItem);
menu.Preset("Morning · Warsaw", p => p.Selection(targetItem, inventory.Where(n => n.Site == "Warsaw").Cast<object?>())
    .Selection(taskItem, new object?[] { "PatchStatus", "Certificates", "DiskSpace" }).Form(options, new Dictionary<string, object?> { ["Parallel"] = 2m, ["DryRun"] = true })
    .WithPreview(new KeyValueList().Add("Scope", "Warsaw servers").Add("Tasks", "Security and disk space").Add("Dry run", "yes")));
menu.Preset("Health · all sites", p => p.Selection(targetItem, inventory.Where(n => n.Offline == "").Cast<object?>())
    .Selection(taskItem, new object?[] { "EventErrors", "Services", "DiskSpace", "Uptime" }));
menu.Item("Presets", i => i.Hotkey('p').WithIcon(GlyphIcon.Favorite).Presets()); menu.Separator();
menu.Item("Review & run", i => i.Hotkey('r').WithIcon(GlyphIcon.Run).Review(_ => {
    targets = inventory.Where(n => targetItem.GetSelection<Node>().Contains(n) && n.Offline == "").ToArray();
    checks = catalog.Where(c => taskItem.GetSelection<string>().Contains(c.Id)).ToArray(); ApplyOptions(options.GetValues());
    ReviewModel review = new("Review & run") { Description = "Everything the run uses; the command carries the same options." };
    review.Row("Targets", string.Join(", ", targets.Select(n => n.Name))); review.Row("Tasks", string.Join(", ", checks.Select(c => c.Name)));
    review.Row("Servers at a time", parallel.ToString()); review.Row("Report", report + " (" + format.ToUpperInvariant() + ")");
    review.Row("Email", notify ? email : "off"); review.Row("Dry run", dryRun ? "yes" : "no");
    if (targets.Length == 0) review.Problem("Pick at least one target", targetItem);
    if (checks.Length == 0) review.Problem("Pick at least one task", taskItem);
    if (notify && !email.Contains('@')) review.Problem("Enter the email recipient in Options", optionItem);
    review.Command = "Invoke-ServerToolbox " + CommandLine.Argument("ComputerName", targets.Select(n => n.Name).ToList(), CommandStyle.PowerShell)
        + " " + CommandLine.Argument("Task", checks.Select(c => c.Id).ToList(), CommandStyle.PowerShell) + " " + options.BuildCommand();
    review.Run = ctx => Execute(ctx, Plan()); return review;
}));
menu.Item("History", i => i.Hotkey('h').WithIcon(GlyphIcon.List).Page(_ => History()).State(() => history.Count + " runs"));
menu.Separator(); menu.Exit("Quit");
if (snapshot) {
    List<Work> work = Plan();
    int done = step < 2 ? 0 : step == 2 ? Math.Min(work.Count, targets.Length + 2) : step == 3 ? work.Count * 2 / 3 : work.Count;
    int phase = step < 2 ? step : step < 4 ? 2 : step == 4 ? 3 : 4;
    Terminal.Write(Dashboard(work, done, phase));
} else menu.Run(new MenuOptions { Layout = MenuLayout.Shell, AppName = "Server toolbox gallery" });

record Node(string Name, string Domain, string Site, string Role, string Detail, string Offline) {
    public override string ToString() => Name;
}
record Check(string Id, string Name, string Category, string Description, string Expected, double Minutes, bool Changes);
record Work(Node Node, Check Check, StatusKind Status, string Actual);
record HistoryRow(string When, int Servers, int Checks, int Passed, int Warnings, int Failed, TimeSpan Elapsed);

Run it yourself

Three steps in PowerShell, four in C#. Every command copies with one click.

  1. Install the module

    Once. Windows PowerShell 5.1 or PowerShell 7 on Windows, macOS or Linux.

    Install-Module ConsoleMonster -Scope CurrentUser
  2. Save the program

    Download it, or copy it from above into toolbox.ps1.

  3. Run it

    From the folder you saved it in.

    ./toolbox.ps1
  1. Create a project

    A plain console app; .NET 8 or later.

    dotnet new console -n Toolbox; cd Toolbox
  2. Add ConsoleMonster

    The only package the program needs.

    dotnet add package ConsoleMonster
  3. Replace Program.cs

    Download it, or copy the program from above.

  4. Run it

    dotnet run

Or skip the copying. Every demo ships with the module and the project template.

Show-ConsoleDemo toolbox
Get-ConsoleDemo toolbox -Path ./toolbox.ps1
dotnet new install ConsoleMonster.Templates
dotnet new consolemonster --demo toolbox -n Toolbox

Make it yours

Start with these, then read the rest of the file.

  1. Replace the inline inventory with your server catalog.

  2. Connect the selected operations to your own cancellable work.

  3. Store completed runs and connect report delivery.

More terminal apps

All demos