Public/network/Show-PingMonitor.ps1

#Requires -Version 5.1

function Show-PingMonitor {
    <#
        .SYNOPSIS
            Interactive real-time ping monitor with ANSI-colored console display
 
        .DESCRIPTION
            Continuously pings one or more hosts and displays live statistics in an
            interactive console interface with ANSI color-coded status indicators.
            Supports keyboard controls for sorting, pausing, clearing statistics,
            and quitting. Press Q to quit, S to cycle sort, C to clear stats,
            P to pause/resume monitoring.
 
        .PARAMETER ComputerName
            One or more hostnames or IP addresses to monitor.
 
        .PARAMETER RefreshInterval
            Refresh interval in seconds. Default: 2. Valid range: 1-60.
 
        .PARAMETER PingTimeoutMs
            Timeout per ping in milliseconds. Default: 2000. Valid range: 500-10000.
 
        .PARAMETER NoClear
            Suppresses the console clear on exit so the final frame remains visible
            in the scrollback buffer.
 
        .PARAMETER NoColor
            Disables ANSI color output for terminals that do not support escape sequences.
 
        .EXAMPLE
            Show-PingMonitor -ComputerName '192.168.1.1', 'google.com'
 
            Monitors two hosts with default settings. Press Q to quit, S to sort,
            C to clear statistics, P to pause or resume monitoring.
 
        .EXAMPLE
            Show-PingMonitor -ComputerName 'SRV01' -RefreshInterval 5 -NoColor
 
            Monitors a single server with 5-second refresh and ANSI colors disabled.
 
        .EXAMPLE
            'SRV01', 'SRV02', 'SRV03' | Show-PingMonitor -PingTimeoutMs 1000
 
            Monitors three servers via pipeline input with a 1-second ping timeout.
 
        .OUTPUTS
            None
            This function renders an interactive TUI and does not produce pipeline output.
 
        .NOTES
            Author: Franck SALLET
            Version: 2.0.0
            Last Modified: 2026-04-11
            Requires: PowerShell 5.1+ / Windows only
            Requires: Interactive console (not ISE or redirected output)
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
 
        .LINK
            https://learn.microsoft.com/en-us/dotnet/api/system.net.networkinformation.ping
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    [OutputType([void])]
    param (
        [Parameter(Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 0)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Name', 'DNSHostName')]
        [string[]]$ComputerName,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 60)]
        [int]$RefreshInterval = 2,

        [Parameter(Mandatory = $false)]
        [ValidateRange(500, 10000)]
        [int]$PingTimeoutMs = 2000,

        [Parameter(Mandatory = $false)]
        [switch]$NoClear,

        [Parameter(Mandatory = $false)]
        [switch]$NoColor
    )

    begin {
        if ($Host.Name -eq 'Windows PowerShell ISE Host') {
            Write-Error -Message "[$($MyInvocation.MyCommand)] ISE is not supported. Use Windows Terminal, ConHost, or a remote SSH session."
            return
        }

        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting"
        $hostList = [System.Collections.Generic.List[string]]::new()
    }

    process {
        if ($Host.Name -eq 'Windows PowerShell ISE Host') { return }
        foreach ($targetHost in $ComputerName) {
            if (-not $hostList.Contains($targetHost)) {
                $hostList.Add($targetHost)
            }
        }
    }

    end {
        if ($Host.Name -eq 'Windows PowerShell ISE Host') { return }
        if ($hostList.Count -eq 0) {
            Write-Warning -Message "[$($MyInvocation.MyCommand)] No hosts specified"
            return
        }

        # ---- ANSI setup ----
        $esc      = [char]27
        $useColor = -not $NoColor

        $bold   = if ($useColor) { "${esc}[1m" }  else { '' }
        $dim    = if ($useColor) { "${esc}[90m" } else { '' }
        $reset  = if ($useColor) { "${esc}[0m" }  else { '' }
        $cyan   = if ($useColor) { "${esc}[96m" } else { '' }
        $white  = if ($useColor) { "${esc}[97m" } else { '' }
        $green  = if ($useColor) { "${esc}[92m" } else { '' }
        $red    = if ($useColor) { "${esc}[91m" } else { '' }
        $yellow = if ($useColor) { "${esc}[93m" } else { '' }

        # ---- Per-host statistics ----
        $statsTable = @{}
        foreach ($targetHost in $hostList) {
            $statsTable[$targetHost] = @{
                Sent = 0; Received = 0; Lost = 0
                LastMs = -1; MinMs = [int]::MaxValue; MaxMs = 0; TotalMs = [long]0
                Status = 'Pending'
            }
        }

        # Column width for host names
        $maxHostLen = 4
        foreach ($targetHost in $hostList) {
            if ($targetHost.Length -gt $maxHostLen) { $maxHostLen = $targetHost.Length }
        }

        # ---- Sort modes ----
        $sortModes = @('Host', 'Status', 'LastMs', 'Loss')
        $sortIndex = 0
        $sortMode  = $sortModes[$sortIndex]

        $running      = $true
        $paused       = $false
        $monitorStart = Get-Date
        $pinger       = [System.Net.NetworkInformation.Ping]::new()

        $previousCtrlC         = [Console]::TreatControlCAsInput
        $previousCursorVisible = [Console]::CursorVisible

        try {
            [Console]::TreatControlCAsInput = $true
            [Console]::CursorVisible        = $false
            [Console]::Clear()

            while ($running) {
                $frameStart = [Diagnostics.Stopwatch]::StartNew()

                # ---- Ping all hosts (skip when paused) ----
                if (-not $paused) {
                    foreach ($targetHost in $hostList) {
                        $hostStat = $statsTable[$targetHost]
                        $hostStat.Sent++
                        try {
                            $pingReply = $pinger.Send($targetHost, $PingTimeoutMs)
                            if ($pingReply.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) {
                                $hostStat.Received++
                                $roundtrip = [int]$pingReply.RoundtripTime
                                $hostStat.LastMs = $roundtrip
                                $hostStat.TotalMs += $roundtrip
                                if ($roundtrip -lt $hostStat.MinMs) { $hostStat.MinMs = $roundtrip }
                                if ($roundtrip -gt $hostStat.MaxMs) { $hostStat.MaxMs = $roundtrip }
                                $hostStat.Status = 'Up'
                            }
                            else {
                                $hostStat.Lost++
                                $hostStat.Status = 'Down'
                            }
                        }
                        catch {
                            $hostStat.Lost++
                            $hostStat.Status = 'Down'
                        }
                    }
                }

                # ---- Build display frame ----
                $frameBuilder = [System.Text.StringBuilder]::new(4096)
                $elapsed      = (Get-Date) - $monitorStart
                $elapsedStr   = '{0:00}:{1:00}:{2:00}' -f [math]::Floor($elapsed.TotalHours), $elapsed.Minutes, $elapsed.Seconds

                # Header
                $pauseLabel = if ($paused) { " ${yellow}(PAUSED)${reset}" } else { '' }
                [void]$frameBuilder.AppendLine("${bold}${cyan}=== PING MONITOR ===${reset}${pauseLabel} ${dim}Elapsed: ${elapsedStr}${reset}")
                [void]$frameBuilder.AppendLine('')

                # Column headers — highlight active sort column
                $hHost   = if ($sortMode -eq 'Host')   { "${cyan}${bold}" } else { $bold }
                $hStatus = if ($sortMode -eq 'Status') { "${cyan}${bold}" } else { $bold }
                $hLast   = if ($sortMode -eq 'LastMs') { "${cyan}${bold}" } else { $bold }
                $hLoss   = if ($sortMode -eq 'Loss')   { "${cyan}${bold}" } else { $bold }
                $columnLine = " ${hHost}$('HOST'.PadRight($maxHostLen))${reset} ${hStatus}$('STATUS'.PadRight(8))${reset} ${hLast}$('LAST(ms)'.PadLeft(8))${reset} ${bold}$('MIN(ms)'.PadLeft(8))${reset} ${bold}$('MAX(ms)'.PadLeft(8))${reset} ${bold}$('AVG(ms)'.PadLeft(8))${reset} ${bold}$('SENT'.PadLeft(6))${reset} ${bold}$('RECV'.PadLeft(6))${reset} ${hLoss}$('LOSS'.PadLeft(7))${reset}"
                [void]$frameBuilder.AppendLine($columnLine)
                $sepLine = " ${dim}$('-' * $maxHostLen) $('-' * 8) $('-' * 8) $('-' * 8) $('-' * 8) $('-' * 8) $('-' * 6) $('-' * 6) $('-' * 7)${reset}"
                [void]$frameBuilder.AppendLine($sepLine)

                # Sort hosts
                $sortedHosts = switch ($sortMode) {
                    'Host'   { $hostList | Sort-Object -Property { $_ } }
                    'Status' { $hostList | Sort-Object -Property { switch ($statsTable[$_].Status) { 'Down' { 0 } 'Pending' { 1 } 'Up' { 2 } default { 3 } } } }
                    'LastMs' { $hostList | Sort-Object -Property { $statsTable[$_].LastMs } -Descending }
                    'Loss'   { $hostList | Sort-Object -Property { $s = $statsTable[$_]; if ($s.Sent -gt 0) { $s.Lost / $s.Sent } else { 0 } } -Descending }
                }

                $upCount = 0; $downCount = 0; $pendingCount = 0
                foreach ($displayHost in $sortedHosts) {
                    $hostStat = $statsTable[$displayHost]

                    switch ($hostStat.Status) {
                        'Up'      { $upCount++ }
                        'Down'    { $downCount++ }
                        'Pending' { $pendingCount++ }
                    }

                    $statusColor = switch ($hostStat.Status) {
                        'Up'      { $green }
                        'Down'    { $red }
                        'Pending' { $yellow }
                        default   { $reset }
                    }

                    $hostPad   = $displayHost.PadRight($maxHostLen)
                    $statusPad = $hostStat.Status.PadRight(8)
                    $lastMsStr = if ($hostStat.LastMs -ge 0) { $hostStat.LastMs.ToString().PadLeft(8) } else { '--'.PadLeft(8) }
                    $minMsStr  = if ($hostStat.MinMs -ne [int]::MaxValue) { $hostStat.MinMs.ToString().PadLeft(8) } else { '--'.PadLeft(8) }
                    $maxMsStr  = if ($hostStat.MaxMs -gt 0) { $hostStat.MaxMs.ToString().PadLeft(8) } else { '--'.PadLeft(8) }
                    $avgMsStr  = if ($hostStat.Received -gt 0) { ([math]::Round($hostStat.TotalMs / $hostStat.Received, 1)).ToString('0.0').PadLeft(8) } else { '--'.PadLeft(8) }
                    $sentStr   = $hostStat.Sent.ToString().PadLeft(6)
                    $recvStr   = $hostStat.Received.ToString().PadLeft(6)
                    $lossVal   = if ($hostStat.Sent -gt 0) { [math]::Round(($hostStat.Lost / $hostStat.Sent) * 100, 1) } else { [double]0 }
                    $lossPad   = ('{0:0.0}%' -f $lossVal).PadLeft(7)

                    $lossColor = if ($lossVal -eq 0) { $green } elseif ($lossVal -lt 10) { $yellow } else { $red }

                    $row = " ${white}${hostPad}${reset} ${statusColor}${statusPad}${reset} ${lastMsStr} ${minMsStr} ${maxMsStr} ${avgMsStr} ${sentStr} ${recvStr} ${lossColor}${lossPad}${reset}"
                    [void]$frameBuilder.AppendLine($row)
                }

                # Summary + footer
                [void]$frameBuilder.AppendLine('')
                [void]$frameBuilder.AppendLine(" ${dim}${hostList.Count} hosts${reset} ${dim}|${reset} ${green}${upCount} Up${reset} ${dim}|${reset} ${red}${downCount} Down${reset} ${dim}|${reset} ${yellow}${pendingCount} Pending${reset}")
                [void]$frameBuilder.AppendLine('')
                [void]$frameBuilder.AppendLine(" ${bold}[${cyan}Q${reset}${bold}]${reset}uit ${bold}[${cyan}S${reset}${bold}]${reset}ort ${bold}[${cyan}C${reset}${bold}]${reset}lear ${bold}[${cyan}P${reset}${bold}]${reset}ause ${dim}|${reset} Refresh: ${yellow}${RefreshInterval}s${reset} ${dim}|${reset} Sort: ${yellow}${sortMode}${reset} ${dim}|${reset} Elapsed: ${yellow}${elapsedStr}${reset}")

                # Erase trailing lines
                $height = [math]::Max(24, [Console]::WindowHeight)
                $currentLines = $frameBuilder.ToString().Split("`n").Count
                for ($r = $currentLines; $r -lt $height; $r++) {
                    [void]$frameBuilder.AppendLine("${esc}[2K")
                }

                [Console]::SetCursorPosition(0, 0)
                [Console]::Write($frameBuilder.ToString())
                $frameStart.Stop()

                # ---- Input handling ----
                $sleepMs    = [math]::Max(100, ($RefreshInterval * 1000) - $frameStart.ElapsedMilliseconds)
                $inputTimer = [Diagnostics.Stopwatch]::StartNew()

                while ($inputTimer.ElapsedMilliseconds -lt $sleepMs) {
                    if ([Console]::KeyAvailable) {
                        $keyInfo = [Console]::ReadKey($true)

                        if (($keyInfo.Key -eq 'C') -and (($keyInfo.Modifiers -band [ConsoleModifiers]::Control) -eq [ConsoleModifiers]::Control)) {
                            $running = $false
                            break
                        }

                        switch ($keyInfo.Key) {
                            'Q'      { $running = $false }
                            'Escape' { $running = $false }
                            'S'      { $sortIndex = ($sortIndex + 1) % $sortModes.Count; $sortMode = $sortModes[$sortIndex] }
                            'C'      {
                                foreach ($resetHost in $hostList) {
                                    $resetStat = $statsTable[$resetHost]
                                    $resetStat.Sent = 0; $resetStat.Received = 0; $resetStat.Lost = 0
                                    $resetStat.LastMs = -1; $resetStat.MinMs = [int]::MaxValue
                                    $resetStat.MaxMs = 0; $resetStat.TotalMs = [long]0
                                    $resetStat.Status = 'Pending'
                                }
                                $monitorStart = Get-Date
                            }
                            'P'      { $paused = -not $paused }
                        }

                        if (-not $running) { break }
                    }
                    Start-Sleep -Milliseconds 50
                }
            }
        }
        finally {
            if ($null -ne $pinger) { $pinger.Dispose() }
            [Console]::CursorVisible        = $previousCursorVisible
            [Console]::TreatControlCAsInput = $previousCtrlC
            if (-not $NoClear) { [Console]::Clear() }
            Write-Information -MessageData 'Ping Monitor stopped.' -InformationAction Continue
        }
    }
}