Fleet filter
Filter five hosts as you type while retaining the fleet-wide health summary above the matching rows.
- Live
- 88×24
- 43 lines
Show-ConsoleDemo fleet-filterdotnet new consolemonster --demo fleet-filterPlan a fleet check, review the command and follow every category through findings and results.
Demos Terminal apps Showcase
Use it when operators need to plan, run and review health checks across several sites.
Fleet health check · corp.example.test · 5 servers · 8 checks 00:00:00 ━━━━━━━━━━━━━━━━━━━━ 40/40 100% · ✓ 34 passed ▴ 2 warnings ✗ 4 failed · in 3.2s ✓ Discover › ✓ Connect › ✗ Check › ✓ Report ╭─ Check map ────────────────────────────────────╮ ╭─ Categories ───────────────────────────────────────────╮ ╭─ Running ──────────────────────────────────────╮ │ DC01 ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ │ │ ✗ Identity ━━━━━━━━━━━━━━━━━━━━ 100% ✓ 9 ✗ 1 ▴ 0 │ │ ✗ Checks complete │ │ DC02 ✓ ✗ ✓ ✓ ✓ ✓ ✓ ✓ │ │ ✓ DNS ━━━━━━━━━━━━━━━━━━━━ 100% ✓ 5 ✗ 0 ▴ 0 │ │ │ │ FS01 ✓ ✓ ✓ ✓ ✓ ✗ ✓ ✓ │ │ ✗ Certificates ━━━━━━━━━━━━━━━━━━━━ 100% ✓ 8 ✗ 1 ▴ 1 │ │ │ │ SQL01 ✓ ✓ ✓ ✓ ✓ ✓ ✗ ▴ │ │ ✗ Updates ━━━━━━━━━━━━━━━━━━━━ 100% ✓ 4 ✗ 1 ▴ 0 │ │ │ │ WEB01 ✓ ✓ ✓ ✗ ▴ ✓ ✓ ✓ │ │ ✗ Storage ━━━━━━━━━━━━━━━━━━━━ 100% ✓ 4 ✗ 1 ▴ 0 │ │ │ │ ✓ 34 passed ✗ 4 failed ▴ 2 warnings │ │ ▴ Services ━━━━━━━━━━━━━━━━━━━━ 100% ✓ 4 ✗ 0 ▴ 1 │ │ │ │ │ │ ────────────────────────────────────────────────────── │ │ │ │ │ │ Domain corp.example.test │ │ │ │ │ │ Servers 5 · 3 sites │ │ │ │ │ │ Checks 8 · 40 in all │ │ │ │ │ │ Report HTML │ │ │ ╰────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────╯ ╭─ Findings ─────────────────────────────────────────────────────────────────────╮ ╭─ Slowest checks ─────────────────────────────────────╮ ╭─ Durations ──────╮ │ Server Check Expected Actual │ │ Check Server Took │ │ 18│██ │ │ ────── ─────────────── ──────────── ──────────────────────────── │ │ ─────────────── ────── ───── │ │ │██ │ │ DC02 Time offset under 1 s 4.7 s │ │ Missing updates WEB01 10.9s │ │ │██ ██ │ │ WEB01 Cert expiry 30+ days 9 days │ │ Missing updates SQL01 9.8s │ │ │██ ██ │ │ WEB01 Cert chain trusted root intermediate ends in 40 days │ │ Missing updates FS01 8.7s │ │ │██ ██ ▃▃ │ │ FS01 Missing updates 0 3 updates │ │ Missing updates DC02 7.6s │ │ │██ ██ ██ ▁▁▁ │ │ SQL01 Free space 15% free 6% on data volume │ │ Missing updates DC01 6.5s │ │ 0│██ ██ ██ ███ │ │ SQL01 Auto services all running 1 restarted │ │ │ │ 0 2 4 8 16 │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────╯ ╰──────────────────╯ ╭─ Log ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ DC01 · Auto services: all running │ │ DC02 · Auto services: all running │ │ FS01 · Auto services: all running │ │ ▴ WARN SQL01 · Auto services: 1 restarted │ │ WEB01 · Auto services: all running │ │ Preparing HTML report: /tmp/reports/fleet │ │ Report ready · 6 findings to review │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ Ctrl+C cancel · results keep the findings
The whole program in one file, with its sample data at the top. It drew the recording above; both languages draw the same cells.
# Fleet health check: 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
DC01|corp.example.test|Warsaw|Domain controller|10.20.1.11|
DC02|corp.example.test|Warsaw|Domain controller|10.20.1.12|
FS01|corp.example.test|Dublin|File|10.30.1.21|
SQL01|corp.example.test|Dublin|Database|10.30.1.31|
WEB01|corp.example.test|Berlin|Web|10.40.1.41|
FS22|corp.example.test|Berlin|File|10.40.1.22|offline since 07:12
LAB01|lab.example.test|Lab|Web|192.168.50.40|
LAB02|lab.example.test|Lab|Database|192.168.50.30|
'@ | ConvertFrom-Csv -Delimiter '|' | ForEach-Object {
$_ | Add-Member -NotePropertyName Online -NotePropertyValue (-not $_.Offline) -PassThru
}
$catalog = @'
Id|Name|Category|Description|Expected|Seconds|Changes
SecureChannel|Secure channel|Identity|Tests the computer account trust with the domain.|trusted|1.2|False
TimeOffset|Time offset|Identity|Compares the clock with the domain time source.|under 1 s|0.8|False
ForwardRecord|Forward record|DNS|Compares the A record with the server address.|matches IP|0.6|False
CertExpiry|Cert expiry|Certificates|Finds machine certificates expiring within 30 days.|30+ days|1.8|False
CertChain|Cert chain|Certificates|Builds each certificate chain to a trusted root.|trusted root|3.5|False
MissingUpdates|Missing updates|Updates|Lists missing security updates older than 14 days.|0|6.5|False
FreeSpace|Free space|Storage|Measures free space on every fixed volume.|15% free|1.3|False
AutoServices|Auto services|Services|Checks that automatic services are running.|all running|2.2|False
'@ | ConvertFrom-Csv -Delimiter '|' | ForEach-Object {
$_.Seconds = [double]::Parse($_.Seconds, [cultureinfo]::InvariantCulture)
$_.Changes = $_.Changes -eq 'True'; $_
}
$findings = @{
'DC02/TimeOffset' = @('Fail', '4.7 s')
'FS01/MissingUpdates' = @('Fail', '3 updates')
'SQL01/FreeSpace' = @('Fail', '6% on data volume')
'WEB01/CertExpiry' = @('Fail', '9 days')
'WEB01/CertChain' = @('Warn', 'intermediate ends in 40 days')
'SQL01/AutoServices' = @('Warn', '1 restarted')
}
$history = @(
[pscustomobject] @{ When = 'Mon 19 Oct 21:04'; Servers = '3'; Checks = '4'; Result = '10 passed · 1 warn · 1 failed'; Duration = '00:06:12' }
[pscustomobject] @{ When = 'Tue 20 Oct 07:30'; Servers = '2'; Checks = '3'; Result = '6 passed · 0 warn · 0 failed'; Duration = '00:01:06' }
)
$reportRoot = if ($IsWindows) { 'C:\Reports\Fleet' } else { '/tmp/reports/fleet' }
$plan = @{
Domain = 'corp.example.test'; Targets = @($inventory | Where-Object { $_.Domain -eq 'corp.example.test' -and -not $_.Offline } | Select-Object -First 5)
Checks = @($catalog | Where-Object Id -In @('SecureChannel', 'TimeOffset', 'ForwardRecord', 'CertExpiry', 'CertChain', 'MissingUpdates', 'FreeSpace', 'AutoServices'))
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] @{
Order = $ci * $plan.Targets.Count + $ni
Node = $node; Check = $check; Cell = $ni * $plan.Checks.Count + $ci
Status = $(if ($outcome) { $outcome[0] } else { 'Pass' })
Actual = $(if ($outcome) { $outcome[1] } else { $check.Expected })
Seconds = [math]::Round($check.Seconds * (1 + $ni * .17), 1)
}
}
}
}
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 Format-Seconds($Value) { $Value.ToString('0.0', [cultureinfo]::InvariantCulture) + 's' }
function Get-Findings($Work) {
$rows = @($Work | Where-Object Status -NE Pass | ForEach-Object {
[pscustomobject] @{ Server = $_.Node.Name; Check = $_.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', 'Check', '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', 'Checks', '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 $(Format-Seconds $Check.Seconds)"
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', 'Check', '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 ($category in @($Work.Check.Category | Select-Object -Unique)) {
$planned = @($Work | Where-Object { $_.Check.Category -eq $category })
$ready = @($finished | Where-Object { $_.Check.Category -eq $category })
$status = if ($ready.Count -eq $planned.Count) { Get-Verdict $ready }
elseif (@($active | Where-Object { $_.Check.Category -eq $category }).Count) { 'Running' } else { 'Pending' }
New-ConsoleLane $category -Total $planned.Count -Done $ready.Count -Status $status `
-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 100 {
New-ConsolePanel -Title 'Check map' {
$states = @('Pending') * ($plan.Targets.Count * $plan.Checks.Count)
for ($i = 0; $i -lt $states.Count; $i++) { if ($i -notin $Work.Cell) { $states[$i] = 'Skip' } }
foreach ($w in $finished) { $states[$w.Cell] = $w.Status }
foreach ($w in $active) { $states[$w.Cell] = 'Running' }
New-ConsoleCellMap -Status $states -Columns $plan.Checks.Count -RowLabels $plan.Targets.Name -LegendLines 2
}
New-ConsolePanel -Title Categories -Weight 1.15 {
$lanes; New-ConsoleRule
New-ConsoleKeyValue -Data ([ordered] @{
Domain = $plan.Domain; Servers = "$($plan.Targets.Count) · $(@($plan.Targets.Site | Select-Object -Unique).Count) sites"
Checks = "$($plan.Checks.Count) · $($Work.Count) in all"; Report = $plan.Format.ToUpperInvariant()
})
}
New-ConsolePanel -Title Running -Weight 0.95 {
New-ConsoleStatusList {
foreach ($w in $active) { New-ConsoleStatusItem "$($w.Node.Name) · $($w.Check.Name)" -Status Running }
if (-not @($active).Count) {
$text = if ($Phase -ge 3) { 'Checks complete' } else { 'Waiting for connections' }
New-ConsoleStatusItem $text -Status $(if ($Phase -ge 3) { Get-Verdict $finished } else { 'Pending' })
}
}
}
}
New-ConsoleColumns -FixedWidth 0, 0, 20 -CollapseBelow 100 {
New-ConsolePanel -Title Findings -Weight 1.5 { Get-Findings $finished }
New-ConsolePanel -Title 'Slowest checks' {
$rows = @($finished | Sort-Object @{ Expression = 'Seconds'; Descending = $true }, Order | Select-Object -First 5 | ForEach-Object {
[pscustomobject] @{ Check = $_.Check.Name; Server = $_.Node.Name; Took = Format-Seconds $_.Seconds }
})
$columns = @('Check', 'Server', 'Took' | ForEach-Object { @{ Name = $_; Presenter = 'Text' } })
if ($rows.Count) { $rows | Format-ConsoleTable -Property $columns -Border Minimal -NoSort -AsWidget }
else { New-ConsoleLiveTable -Name slowest -Column $columns -Border Minimal }
}
New-ConsolePanel -Title Durations { New-ConsoleHistogram -Name durations -Edge 0, 2, 4, 8, 16 -Height 5 -Value @($finished | ForEach-Object Seconds) }
}
New-ConsolePanel -Title Log {
New-ConsoleLog -NoTime -Height 7 {
New-ConsoleLogEntry "Inventory: $($plan.Domain) · $($plan.Targets.Count) servers · $($Work.Count) checks"
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]Fleet health check[/]' `
-Subtitle "$($plan.Domain) · $($plan.Targets.Count) servers · $($plan.Checks.Count) checks" `
-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]Fleet health check[/]' -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
Checks = [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) checks 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 checks'] = { Invoke-Work $MenuContext @($plan.CompletedWork | Where-Object Status -EQ Fail) }
}
$next['View history'] = { $MenuContext.ShowResult((New-ConsoleRunResult 'Run history' -Details (Get-History))) }
New-ConsoleRunResult 'Fleet check 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' } }
}
function Set-Options($Values) {
foreach ($name in 'Parallel', 'Report', 'Format', 'Notify', 'Email') { $plan[$name] = $Values[$name] }
}
$checkForm = New-ConsoleForm 'New check' -Command Invoke-FleetCheck -SubmitText 'Run checks' `
-Description 'Choose the scope, checks and run options.' {
New-ConsoleField Domain Domain -Page Scope -Type Select -Choices @($inventory.Domain | Select-Object -Unique) -Default $plan.Domain -Parameter Domain
New-ConsoleField Servers Servers -Page Scope -Type MultiSelect -DependsOn Domain -Minimum 1 -Parameter ComputerName `
-Display Name -CommandValue Name -GroupBy Site -Badge Role -Disabled Offline -Selected Online `
-Describe { "$($_.Role) · $($_.Detail)" } -Default $plan.Targets.Name -ArgumentList (, $inventory) -LoadChoices {
param($Values, $Inventory)
Start-Sleep -Milliseconds 350
$Inventory | Where-Object Domain -EQ $Values.Domain
}
New-ConsoleField Checks Checks -Page Checks -Type MultiSelect -Choices $catalog -Display Name -CommandValue Id `
-GroupBy Category -Describe Description -Badge { "~$(Format-Seconds $_.Seconds)" } -Preview { Get-Preview $_ } `
-Default $plan.Checks.Id -Minimum 1 -Parameter Check
Get-OptionFields -Page Options
}
$menu = New-ConsoleMenu 'Fleet health check' -Layout Shell -Subtitle 'Tue 20 Oct 2026 · 09:30' {
New-ConsoleMenuPreset 'Morning · identity & DNS' -Form $checkForm -Values @{
Servers = @($inventory | Where-Object Role -EQ 'Domain controller' | ForEach-Object Name)
Checks = @('SecureChannel', 'TimeOffset', 'ForwardRecord'); Parallel = 2
} -Preview { New-ConsoleKeyValue -Data ([ordered] @{ Scope = 'Domain controllers'; Checks = 'Identity and DNS' }) }
New-ConsoleMenuPreset 'Nightly · all sites' -Form $checkForm -Values @{
Servers = @($inventory | Where-Object { $_.Domain -eq $plan.Domain -and -not $_.Offline } | ForEach-Object Name)
Checks = $catalog.Id; Parallel = 3; Notify = $true
} -Preview { New-ConsoleKeyValue -Data ([ordered] @{ Scope = 'Every online server'; Checks = 'All categories' }) }
New-ConsoleMenuPreset 'Patch Tuesday · updates' -Form $checkForm -Values @{ Checks = @('MissingUpdates', 'AutoServices'); Format = 'Csv' }
New-ConsoleMenuPreset 'Certificates · web tier' -Form $checkForm -Values @{ Servers = @('WEB01'); Checks = @('CertExpiry', 'CertChain', 'ForwardRecord') }
New-ConsoleMenuItem Presets -Hotkey p -IconName Favorite -Presets
New-ConsoleMenuItem 'New check' -Hotkey n -IconName Run -Description 'Choose a domain, servers and checks; review the command and run.' -Form $checkForm -OnSubmit {
$plan.Domain = [string] $Values.Domain
$plan.Targets = @($inventory | Where-Object { $_.Domain -eq $plan.Domain -and -not $_.Offline -and $_.Name -in $Values.Servers })
$plan.Checks = @($catalog | Where-Object Id -In $Values.Checks)
Set-Options $Values
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 'Fleet health check gallery' }
// Fleet health check: 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("DC01", "corp.example.test", "Warsaw", "Domain controller", "10.20.1.11", ""),
new("DC02", "corp.example.test", "Warsaw", "Domain controller", "10.20.1.12", ""),
new("FS01", "corp.example.test", "Dublin", "File", "10.30.1.21", ""),
new("SQL01", "corp.example.test", "Dublin", "Database", "10.30.1.31", ""),
new("WEB01", "corp.example.test", "Berlin", "Web", "10.40.1.41", ""),
new("FS22", "corp.example.test", "Berlin", "File", "10.40.1.22", "offline since 07:12"),
new("LAB01", "lab.example.test", "Lab", "Web", "192.168.50.40", ""),
new("LAB02", "lab.example.test", "Lab", "Database", "192.168.50.30", ""),
};
Check[] catalog = {
new("SecureChannel", "Secure channel", "Identity", "Tests the computer account trust with the domain.", "trusted", 1.2, false),
new("TimeOffset", "Time offset", "Identity", "Compares the clock with the domain time source.", "under 1 s", 0.8, false),
new("ForwardRecord", "Forward record", "DNS", "Compares the A record with the server address.", "matches IP", 0.6, false),
new("CertExpiry", "Cert expiry", "Certificates", "Finds machine certificates expiring within 30 days.", "30+ days", 1.8, false),
new("CertChain", "Cert chain", "Certificates", "Builds each certificate chain to a trusted root.", "trusted root", 3.5, false),
new("MissingUpdates", "Missing updates", "Updates", "Lists missing security updates older than 14 days.", "0", 6.5, false),
new("FreeSpace", "Free space", "Storage", "Measures free space on every fixed volume.", "15% free", 1.3, false),
new("AutoServices", "Auto services", "Services", "Checks that automatic services are running.", "all running", 2.2, false),
};
Dictionary<string, (StatusKind Status, string Actual)> findings = new() {
["DC02/TimeOffset"] = (StatusKind.Fail, "4.7 s"),
["FS01/MissingUpdates"] = (StatusKind.Fail, "3 updates"),
["SQL01/FreeSpace"] = (StatusKind.Fail, "6% on data volume"),
["WEB01/CertExpiry"] = (StatusKind.Fail, "9 days"),
["WEB01/CertChain"] = (StatusKind.Warn, "intermediate ends in 40 days"),
["SQL01/AutoServices"] = (StatusKind.Warn, "1 restarted"),
};
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\Fleet" : "/tmp/reports/fleet";
Node[] targets = inventory.Where(n => n.Domain == "corp.example.test" && n.Offline == "").Take(5).ToArray();
Check[] checks = catalog.Where(c => new[] { "SecureChannel", "TimeOffset", "ForwardRecord", "CertExpiry", "CertChain", "MissingUpdates", "FreeSpace", "AutoServices" }.Contains(c.Id)).ToArray();
string domain = "corp.example.test", format = "Html", report = reportRoot, email = "[email protected]";
int parallel = 3;
bool notify = 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, ci) => targets.Select((n, ni) => {
var outcome = findings.GetValueOrDefault(n.Name + "/" + c.Id, (StatusKind.Pass, c.Expected));
return new Work(n, c, ni * checks.Length + ci, outcome.Item1, outcome.Item2, Math.Round(c.Seconds * (1 + ni * .17), 1));
})).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;
string Seconds(double seconds) => seconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + "s";
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", "Check", "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", "Checks", "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 " + Seconds(c.Seconds))
.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", "Check", "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 (string category in work.Select(w => w.Check.Category).Distinct()) {
Work[] planned = work.Where(w => w.Check.Category == category).ToArray();
Work[] ready = finished.Where(w => w.Check.Category == category).ToArray();
Lane lane = lanes.Add(category, category, 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.Check.Category == category) ? StatusKind.Running : StatusKind.Pending;
}
LogRegion log = new() { ShowTime = false, PreferredHeight = 7 };
log.Write($"Inventory: {domain} · {targets.Length} servers · {work.Count} checks");
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");
CellMap map = new() { Columns = checks.Length, LegendLines = 2 };
foreach (Node node in targets) map.RowLabels.Add(node.Name);
map.Resize(targets.Length * checks.Length);
for (int i = 0; i < map.Count; i++) if (!work.Any(w => w.Cell == i)) map.Set(i, StatusKind.Skip);
foreach (Work w in finished) map.Set(w.Cell, w.Status);
foreach (Work w in active) map.Set(w.Cell, StatusKind.Running);
StatusList running = new("running");
foreach (Work w in active) running.Add(Markup.Escape(w.Node.Name + " · " + w.Check.Name), StatusKind.Running);
if (active.Length == 0) running.Add(phase >= 3 ? "Checks complete" : "Waiting for connections", phase >= 3 ? Verdict(finished) : StatusKind.Pending);
KeyValueList facts = new KeyValueList().Add("Domain", Markup.Escape(domain)).Add("Servers", targets.Length + " · " + targets.Select(n => n.Site).Distinct().Count() + " sites")
.Add("Checks", checks.Length + " · " + work.Count + " in all").Add("Report", format.ToUpperInvariant());
Table slow = TableOf(new[] { "Check", "Server", "Took" });
foreach (Work w in finished.OrderByDescending(w => w.Seconds).Take(5)) slow.AddRow(w.Check.Name, w.Node.Name, Seconds(w.Seconds));
Histogram durations = new(new double[] { 0, 2, 4, 8, 16 }, "durations") { Height = 5 };
foreach (Work w in finished) durations.Add(w.Seconds);
Columns top = new Columns().Add(new Panel(map, "Check map"))
.Add(new Panel(new Rows(lanes, new Rule(), facts), "Categories"), LayoutSize.Fraction(1.15))
.Add(new Panel(running, "Running"), LayoutSize.Fraction(.95));
top.CollapseBelow = 100;
Columns bottom = new Columns().Add(new Panel(Findings(finished), "Findings"), LayoutSize.Fraction(1.5))
.Add(new Panel(slow, "Slowest checks")).Add(new Panel(durations, "Durations"), LayoutSize.Fixed(20));
bottom.CollapseBelow = 100;
return new Rows(summary, phases, top, bottom, new Panel(log, "Log"));
}
Dashboard Dashboard(List<Work> work, int done, int phase) => new("[bold]Fleet health check[/]", Body(work, done, phase)) {
Subtitle = $"{domain} · {targets.Length} servers · {checks.Length} checks", 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]Fleet health check[/]", 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("Fleet check finished", Verdict(work)) {
Summary = $"{work.Count} checks 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 checks", 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";
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]");
}
Menu menu = new("Fleet health check") { Layout = MenuLayout.Shell, Subtitle = "Tue 20 Oct 2026 · 09:30" };
Form checkForm = new("New check") { Command = "Invoke-FleetCheck", SubmitText = "Run checks", Description = "Choose the scope, checks and run options." };
FormPage scope = checkForm.Page("Scope", "Which servers to check.");
ChoiceField domains = scope.Select("Domain", "Domain", inventory.Select(n => n.Domain).Distinct().Select(d => new Choice(d)));
domains.Default = domain; domains.Parameter = "Domain";
ChoiceField servers = scope.MultiSelect("Servers", "Servers"); servers.Minimum = 1; servers.Parameter = "ComputerName"; servers.DependsOn.Add("Domain");
servers.Loader = async (values, token) => {
await Task.Delay(350, token);
return inventory.Where(n => n.Domain == values.Get<string>("Domain")).Select(n => new Choice(n.Name) {
Title = n.Name, Group = n.Site, Badge = RichText.Plain(n.Role, new RichStyle(ThemeToken.Muted)),
Description = n.Role + " · " + n.Detail, DisabledReason = n.Offline == "" ? null : n.Offline, Selected = n.Offline == ""
});
};
servers.Default = targets.Select(n => n.Name).ToArray();
FormPage choose = checkForm.Page("Checks", "What a healthy server reports appears in the preview.");
ChoiceField operations = choose.MultiSelect("Checks", "Checks", catalog.Select(c => new Choice(c.Id, c.Name) {
Group = c.Category, Description = c.Description, Badge = RichText.Plain("~" + Seconds(c.Seconds), new RichStyle(ThemeToken.Muted)), Selected = checks.Contains(c)
}));
operations.Minimum = 1; operations.Parameter = "Check"; operations.Preview = c => Preview(catalog.First(k => k.Id == (string)c.Value!));
FormPage settings = checkForm.Page("Options", "How the run behaves.");
foreach (Field field in options.Fields) settings.Add(field);
menu.Preset("Morning · identity & DNS", p => p.Form(checkForm, new Dictionary<string, object?> {
["Servers"] = inventory.Where(n => n.Role == "Domain controller").Select(n => n.Name).ToArray(),
["Checks"] = new[] { "SecureChannel", "TimeOffset", "ForwardRecord" }, ["Parallel"] = 2m
}).WithPreview(new KeyValueList().Add("Scope", "Domain controllers").Add("Checks", "Identity and DNS")));
menu.Preset("Nightly · all sites", p => p.Form(checkForm, new Dictionary<string, object?> {
["Servers"] = inventory.Where(n => n.Domain == domain && n.Offline == "").Select(n => n.Name).ToArray(), ["Checks"] = catalog.Select(c => c.Id).ToArray(), ["Parallel"] = 3m, ["Notify"] = true
}).WithPreview(new KeyValueList().Add("Scope", "Every online server").Add("Checks", "All categories")));
menu.Preset("Patch Tuesday · updates", p => p.Form(checkForm, new Dictionary<string, object?> { ["Checks"] = new[] { "MissingUpdates", "AutoServices" }, ["Format"] = "Csv" }));
menu.Preset("Certificates · web tier", p => p.Form(checkForm, new Dictionary<string, object?> { ["Servers"] = new[] { "WEB01" }, ["Checks"] = new[] { "CertExpiry", "CertChain", "ForwardRecord" } }));
menu.Item("Presets", i => i.Hotkey('p').WithIcon(GlyphIcon.Favorite).Presets());
menu.Item("New check", i => i.Hotkey('n').WithIcon(GlyphIcon.Run).Describe("Choose a domain, servers and checks; review the command and run.").Form(checkForm, (context, result) => {
domain = result.Values.Get<string>("Domain", domain);
targets = inventory.Where(n => n.Domain == domain && n.Offline == "" && result.Values.GetList<string>("Servers").Contains(n.Name)).ToArray();
checks = catalog.Where(c => result.Values.GetList<string>("Checks").Contains(c.Id)).ToArray();
ApplyOptions(result.Values); Execute(context, Plan());
}));
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 = "Fleet health check gallery" });
record Node(string Name, string Domain, string Site, string Role, string Detail, string Offline);
record Check(string Id, string Name, string Category, string Description, string Expected, double Seconds, bool Changes);
record Work(Node Node, Check Check, int Cell, StatusKind Status, string Actual, double Seconds);
record HistoryRow(string When, int Servers, int Checks, int Passed, int Warnings, int Failed, TimeSpan Elapsed);
Three steps in PowerShell, four in C#. Every command copies with one click.
Once. Windows PowerShell 5.1 or PowerShell 7 on Windows, macOS or Linux.
Install-Module ConsoleMonster -Scope CurrentUserDownload it, or copy it from above into fleet.ps1.
From the folder you saved it in.
./fleet.ps1A plain console app; .NET 8 or later.
dotnet new console -n Fleet; cd FleetThe only package the program needs.
dotnet add package ConsoleMonsterDownload it, or copy the program from above.
dotnet runOr skip the copying. Every demo ships with the module and the project template.
Show-ConsoleDemo fleetGet-ConsoleDemo fleet -Path ./fleet.ps1dotnet new install ConsoleMonster.Templatesdotnet new consolemonster --demo fleet -n FleetStart with these, then read the rest of the file.
Replace the inline inventory with your server catalog.
Connect the selected operations to your own cancellable work.
Store completed runs and connect report delivery.
Filter five hosts as you type while retaining the fleet-wide health summary above the matching rows.
Show-ConsoleDemo fleet-filterdotnet new consolemonster --demo fleet-filterType a literal query and watch matching table rows update immediately while keeping their columns aligned.
Show-ConsoleDemo live-filterdotnet new consolemonster --demo live-filterWatch a bounded log fill with synthetic events and a recoverable warning.
Show-ConsoleDemo log-taildotnet new consolemonster --demo log-tail