functions/Ping-IpList.ps1

function Ping-IpList {
    <#
    .SYNOPSIS
    Advanced parallel ping utility for multiple IP addresses with history tracking and statistics.
 
    .DESCRIPTION
    Ping-IpList provides powerful network connectivity testing capabilities with features like:
    - Parallel ping execution for multiple targets
    - Continuous monitoring mode
    - Ping history visualization
    - DNS resolution
    - Customizable ping parameters
    - Support for IP ranges and CIDR notation
    - Downtime tracking
 
    By default (table view, no -ShowHistory) only hosts that have responded at least
    once during the run are displayed - a host appears the first time it answers a
    ping and then stays visible (sticky) even if it later starts timing out, so a
    flapping host doesn't blink in and out of the table. Hosts that have never
    responded stay hidden, which keeps the console readable when scanning large
    ranges (e.g. a /24 CIDR) where most addresses time out. Use -ShowAll to display
    every target instead, including hosts that have never responded.
 
    -ShowHistory is a monitor-mode view: it always shows every target regardless of
    -ShowAll, because the point of watching the symbol history (! / .) is to see
    hosts go down and come back, not to have them disappear while down.
 
    Regardless of display mode, a summary is printed once the function stops -
    either because it ran its configured -Count of sequences or because it was
    interrupted with Ctrl+C (useful with -Continuous).
 
    .PARAMETER FromClipBoard
    Reads IP addresses from clipboard.
 
    .PARAMETER ipList
    Array of IP addresses or hostnames to ping. Accepts pipeline input, one address
    per pipeline object (e.g. "8.8.8.8","1.1.1.1" | Ping-IpList).
 
    .PARAMETER range
    IP address range in format "192.168.1.1-192.168.1.254".
 
    .PARAMETER cidr
    CIDR notation subnet like "192.168.1.0/24".
 
    .PARAMETER Count
    Number of ping attempts (default: 4, use -Continuous for endless).
 
    .PARAMETER BufferSize
    Size of ping packet in bytes (default: 32).
 
    .PARAMETER DontFragment
    Sets the Don't Fragment flag in ping packet.
 
    .PARAMETER Ttl
    Time to live value (default: 128).
 
    .PARAMETER Timeout
    Ping timeout in milliseconds (default: 100).
 
    .PARAMETER Continuous
    Enables continuous ping mode.
 
    .PARAMETER ResolveDNS
    Resolves IP addresses to hostnames.
 
    .PARAMETER ShowHistory
    Displays ping history using symbols (! for success, . for failure). This is a
    monitor-mode view and always shows every target, including hosts that have
    never responded - nothing is hidden, regardless of -ShowAll.
 
    .PARAMETER ShowAll
    Displays every target in the console output, including hosts that have never
    responded. Without this switch, the table view only shows hosts that have
    responded at least once during the run (sticky - once shown, a host stays
    visible even if it later times out). Has no effect with -ShowHistory, which
    always shows every target. The "Responding hosts: X/Y" header always reflects
    the full list either way.
 
    .PARAMETER HistoryResetCount
    Number of results to keep in history before reset (default: 100).
 
    .PARAMETER DontSortIpList
    Prevents automatic IP address sorting.
 
    .PARAMETER MaxThreads
    Maximum number of concurrent ping threads (default: 100).
 
    .PARAMETER OutToPipe
    Outputs results to the pipeline instead of the console, once per sequence
    (works with -Continuous). Each object includes a Sequence number and
    Timestamp in addition to the usual IPAddress/ResponsTime/Result/DownTime.
 
    .PARAMETER logEvents
    Logs events related to downtime and uptime.
 
    .EXAMPLE
    # Copy these IPs to your clipboard:
    # 192.168.1.10
    # 8.8.8.8
    # 1.1.1.1
    Ping-IpList -FromClipBoard -ShowHistory
 
    Output:
    2024-11-19 14:01:24, Ping sequence: 1
    192.168.1.10 [t:1ms DownFor:0s]:!
    8.8.8.8 [t:27ms DownFor:0s]:!
    1.1.1.1 [t:18ms DownFor:0s]:!
 
    This example shows how to quickly ping multiple IPs by copying them from any source (text file, Excel, web page).
    Just ensure each IP is on a new line before copying.
 
    .EXAMPLE
    Ping-IpList -ipList "8.8.8.8","1.1.1.1" -Count 10
 
    Output:
    IPAddress ResponsTime Result DownTime
    --------- ----------- ------ --------
    1.1.1.1 18 Success
    8.8.8.8 27 Success
 
    .EXAMPLE
    Ping-IpList -range "192.168.1.1-192.168.1.10" -Continuous -ShowHistory
 
    Output:
    2024-11-19 13:57:24, Ping sequence: 7
    192.168.1.1 [t:1ms DownFor:0s]:!!!!!!!
    192.168.1.2 [t:-ms DownFor:9s]:.......
    192.168.1.3 [t:-ms DownFor:9s]:.......
    [...]
 
    .EXAMPLE
    Ping-IpList -cidr "10.0.0.0/29" -ResolveDNS
 
    Output (by default, only hosts that have responded at least once are listed;
    once a host answers it stays listed even if it later times out):
    2024-11-19 13:58:09, Ping sequence: 4, Responding hosts: 3/6
    IPAddress ResponsTime Result DownTime
    --------- ----------- ------ --------
    host1.example.com 1 Success
    10.0.0.2 1 Success
    host3.example.com 1 Success
    [...]
 
    .EXAMPLE
    Ping-IpList -cidr "10.200.12.0/24" -ShowAll
 
    Shows every address in the /24, including hosts that have never responded.
 
    .EXAMPLE
    Ping-IpList -range "192.168.1.1-192.168.1.10" -Continuous
    # Press Ctrl+C to stop
 
    Output (printed once the run stops, whether by Ctrl+C or reaching -Count):
    ==================== Ping Summary ====================
    Started : 2026-08-10 14:20:00
    Ended : 2026-08-10 14:23:45
    Duration: 00:03:45 Sequences: 45
    192.168.1.1 Sent:45 Success:45 Failed:0 Rate:100.0% DownTime:0s
    192.168.1.2 Sent:45 Success:10 Failed:35 Rate: 22.2% DownTime:35s
    [...]
    ========================================================
 
    .NOTES
    Author: Iman Edrisian
    Date: October 2025
    Requires: PowerShell 5.1 or higher
    Tags: Network, Monitoring, Ping
    #>

    [CmdletBinding()]
    param (
        [switch]$FromClipBoard,
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('list')]
        [String[]]$ipList,
        [string]$range,
        [string]$cidr,
        [Alias('n')]
        [int]$Count = 4,
        [Alias('l')]
        [int]$BufferSize = 32,
        [Alias('f')]
        [switch]$DontFragment,
        [Alias('i')]
        [int]$Ttl = 128,
        [int]$Timeout = 100,
        [Alias('c', 't')]
        [switch]$Continuous,
        [Alias('a')]
        [switch]$ResolveDNS,
        [switch]$ShowHistory,
        [switch]$ShowAll,
        [int]$HistoryResetCount = 100,
        [switch]$DontSortIpList,
        [int]$MaxThreads = 100,
        [switch]$OutToPipe,
        [switch]$logEvents
    )

    begin {
        # ValueFromPipeline binds $ipList once per piped object, so accumulate across
        # process calls - without this, only the last piped IP would survive to end{}.
        $pipedIpList = New-Object System.Collections.Generic.List[string]
    }

    process {
        if ($ipList) {
            foreach ($item in $ipList) { $pipedIpList.Add($item) }
        }
    }

    end {
    if ($pipedIpList.Count -gt 0) { $ipList = $pipedIpList.ToArray() }

    if ($range) {
        $ipList = Get-IpAddressesInRange $range
    }
    elseif ($cidr) {
        $ipList = Get-IPAddressesInSubnet $cidr
    }
    else {
        if ($FromClipBoard) { $ipList = Get-Clipboard }
        # Trim leading or trailing white spaces from each entry in the list
        $ipList = $ipList | ForEach-Object { $_.Trim() }

        # Removing any non-IP addresses or blank entry from the list
        $ipList = $ipList -match "\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$"

        # Check if the list does not contain any hostnames
        if (-not $DontSortIpList) {
            if (($ipList -match "^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$").Count -eq 0) {
                $ipList = Sort-IpAddress $ipList
            }
        }
    }

    if ($ResolveDNS) {
        for ($i = 0; $i -lt $ipList.Count; $i++) {
            if ($ipList[$i] -match "\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}") {
                $NameHost = (Resolve-DnsName $ipList[$i] -Type PTR -DnsOnly -ErrorAction SilentlyContinue).NameHost
                if ($NameHost) { 
                    if ($NameHost.Count -gt 1) { $ipList[$i] = $NameHost[0] } else { $ipList[$i] = $NameHost }
                }
            }
        }
    }

    try {
        if ($Continuous) { $Count = -1 }
        $iCount = 0
        $pingHistory = [ordered]@{}
        $scriptStartTime = Get-Date

        while ($iCount -ne $Count) {
               
            # Create and open a runspace pool
            $pool = [runspacefactory]::CreateRunspacePool(1, $MaxThreads)
            $pool.Open()

            # Collect the runspaces
            $runspaces = New-Object System.Collections.ArrayList

            foreach ($ip in $ipList) {
                # Prepare a script block to run in parallel
                $scriptBlock = {
                    param($ip, $BufferSize, $Ttl, $DontFragment, $Timeout, $HistoryResetCount)
                    function Send-Ping {
                        [CmdletBinding()]
                        param (
                            [string]$target,
                            [int]$BufferSize = 32,
                            [switch]$DontFragment,
                            [int]$Ttl = 128,
                            [int]$Timeout = 100 
                        )
    
                        $ping = New-Object System.Net.NetworkInformation.Ping
                        $pingOptions = New-Object System.Net.NetworkInformation.PingOptions($Ttl, $DontFragment)
                        $pingResult = $ping.Send($target, $Timeout, [System.Text.Encoding]::ASCII.GetBytes(("a" * $BufferSize)), $pingOptions)
                        Return $pingResult
                    }
                    # Simulating a ping result
                    $pingResult = send-ping -Target $ip -BufferSize $BufferSize -Ttl $Ttl -DontFragment:$DontFragment -Timeout $Timeout 

                    $pingStatistics = [pscustomobject]@{
                        IPAddress     = $ip
                        ResponsTime   = [double]0.0
                        Result        = ""
                        ResultHistory = ""
                        DownTimeStart = $null
                        DownTime      = $null
                    }

                    if ($pingResult.Status -eq "Success") {
                        $pingStatistics.ResponsTime = $pingResult.RoundtripTime
                        $pingStatistics.Result = $pingResult.Status
                        $pingStatistics.ResultHistory = "!"
                    }
                    else {
                        $pingStatistics.ResponsTime = "-"
                        $pingStatistics.Result = "TimedOut"
                        $pingStatistics.ResultHistory = "."
                    }
        
                    # Return the result
                    return @($ip, $pingStatistics)
                }

                # Create a new PowerShell runspace and configure it
                $powershell = [powershell]::Create().AddScript($scriptBlock).AddArgument($ip).AddArgument($BufferSize).AddArgument($Ttl).AddArgument($DontFragment).AddArgument($Timeout).AddArgument($HistoryResetCount)
                $powershell.RunspacePool = $pool

                # Start the asynchronous execution of the PowerShell instance
                $runspaces.Add([PSCustomObject]@{
                        Pipe        = $powershell
                        AsyncResult = $powershell.BeginInvoke()
                    }) | Out-Null
            }

            # Collect and process the results as they complete
            foreach ($runspace in $runspaces) {
                $result = $runspace.Pipe.EndInvoke($runspace.AsyncResult)
                $runspace.Pipe.Dispose()

                $ipAddress = $result[0]
                $pingStatistics = $result[1]
               
                if ($pingHistory.Contains($ipAddress)) {
                    if ($pingHistory[$ipAddress].ResultHistory.Length -eq $HistoryResetCount) {
                        $pingHistory[$ipAddress].ResultHistory = ""
                    }
                    $pingHistory[$ipAddress].ResponsTime = $pingStatistics.ResponsTime
                    $pingHistory[$ipAddress].Result = $pingStatistics.Result
                    $pingHistory[$ipAddress].ResultHistory += $pingStatistics.ResultHistory
                }
                else {
                    $pingStatistics | Add-Member -NotePropertyName SuccessCount -NotePropertyValue 0
                    $pingStatistics | Add-Member -NotePropertyName FailCount -NotePropertyValue 0
                    # Unlike ResultHistory (which is truncated at -HistoryResetCount), this
                    # never resets during the run, so -ShowHistory coloring stays stable
                    # across a reset instead of flickering back to "ok" for one sequence.
                    $pingStatistics | Add-Member -NotePropertyName ConsecutiveFails -NotePropertyValue 0
                    $pingHistory.Add($ipAddress, $pingStatistics)
                }

                # Track cumulative success/failure counts for the end-of-run summary
                if ($pingStatistics.Result -eq "Success") {
                    $pingHistory[$ipAddress].SuccessCount++
                    $pingHistory[$ipAddress].ConsecutiveFails = 0
                }
                else {
                    $pingHistory[$ipAddress].FailCount++
                    $pingHistory[$ipAddress].ConsecutiveFails++
                }

            }

            # Close and dispose of the runspace pool
            $pool.Close()
            $pool.Dispose()
            
            # Check for DownTime
            foreach ($item in $pingHistory.Values) {
                $timeStamp = Get-Date
                if ($item.Result -eq "TimedOut") {
                    if ($item.DownTimeStart) {
                        $item.DownTime += ($timeStamp - $item.DownTimeStart).TotalSeconds
                        $item.DownTimeStart = $timeStamp
                    }
                    else {
                        $item.DownTimeStart = $timeStamp
                        if ($logEvents) { Write-Log -Message "$($item.IPAddress) went down" -Level Warning -fileNamePrefix "Ping-IpList" }
                    }
                }
                else {
                    if ($logEvents -and $item.DownTimeStart) { Write-Log -Message "$($item.IPAddress) is back online" -Level Info -fileNamePrefix "Ping-IpList" }
                    $item.DownTimeStart = $null
                }
            }
            # -ShowHistory is a monitor-mode view - every target stays on screen so the
            # symbol history and current down/up state are visible for the whole run.
            # -ShowAll asks for the same "everything" behavior in table mode.
            # Otherwise (default table mode), a host is shown once it has responded at
            # least once during this run ("sticky") - it won't be hidden again just
            # because it's currently timing out, only genuinely dead hosts stay hidden.
            $displayItems = if ($ShowHistory -or $ShowAll) { $pingHistory.Values } else { @($pingHistory.Values | Where-Object { $_.SuccessCount -gt 0 }) }

            if (-not $OutToPipe) {
                Clear-Host
                $successCount = ($pingHistory.Values | Where-Object { $_.Result -eq "Success" }).Count
                $totalCount = $pingHistory.Values.Count
                $color = if ($successCount -eq $totalCount) { 'Green' } elseif ($successCount -eq 0) { 'Red' } else { 'Yellow' }
                Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), Ping sequence: $($iCount + 1), Responding hosts: " -NoNewline
                Write-Host "$successCount/$totalCount" -ForegroundColor $color
                if (-not $ShowAll -and -not $ShowHistory) {
                    if ($displayItems.Count -eq 0) {
                        Write-Host "No hosts have responded yet (0/$totalCount silent so far) - use -ShowAll to see every target" -ForegroundColor DarkGray
                    }
                    else {
                        Write-Host "(showing $($displayItems.Count)/$totalCount hosts that have responded at least once, use -ShowAll to include hosts that never respond)" -ForegroundColor DarkGray
                    }
                }
                if ($ShowHistory) {
                    foreach ($item in $displayItems) {
                        # $paddingSize = 20 - $item.IPAddress.length
                        # if ($paddingSize -lt 0) { $paddingSize = 0 }
                        Write-Host -ForegroundColor Green -NoNewline "$($item.IPAddress) ["
                        Write-Host -NoNewline "t:"#.PadLeft($paddingSize, " ")
                        Write-Host -ForegroundColor Green -NoNewline "$($item.ResponsTime)ms "
                        Write-Host -NoNewline "DownFor:"
                        Write-Host -ForegroundColor Green -NoNewline "$([math]::Round($item.DownTime,2))s]:"
                        # Colored on ConsecutiveFails (persists across HistoryResetCount
                        # truncation) rather than parsing ResultHistory, so a long-down host
                        # doesn't flash back to yellow for one sequence right after a reset.
                        if ($item.ConsecutiveFails -ge 2) {
                            Write-Host -ForegroundColor Red "$($item.ResultHistory)"
                        }
                        elseif ($item.ConsecutiveFails -eq 1) {
                            Write-Host -ForegroundColor Yellow "$($item.ResultHistory)"
                        }
                        else {
                            Write-Host "$($item.ResultHistory)"
                        }

                    }
                }
                else {
                    Write-Output -InputObject $displayItems | Select-Object IPAddress, ResponsTime, Result, DownTime | Format-Table -RepeatHeader -AutoSize
                }
            }
            else {
                # Emit every sequence (not just the last one) - the previous "only on the
                # final iteration" check never fired under -Continuous, since $Count is -1
                # there and $iCount never equals $Count - 1. Sequence/Timestamp are added
                # as data instead of a Write-Host banner, keeping pipeline output clean.
                Write-Output -InputObject $displayItems |
                    Select-Object @{Name = 'Sequence'; Expression = { $iCount + 1 } },
                    @{Name = 'Timestamp'; Expression = { Get-Date } },
                    IPAddress, ResponsTime, Result, ResultHistory, DownTime
            }
            $iCount++
            Start-Sleep -Seconds 1
        }




    }
    finally {
        # Runs both when the loop finishes normally (reached -Count) and when interrupted
        # with Ctrl+C, so cleanup and the summary always happen.
        if ($pool) {
            try {
                if ($pool.RunspacePoolStateInfo.State -ne 'Closed') {
                    $pool.Close()
                }
            }
            catch {}
            try { $pool.Dispose() } catch {}
        }

        if ($pingHistory -and $pingHistory.Count -gt 0) {
            $endTime = Get-Date
            $duration = $endTime - $scriptStartTime

            Write-Host ""
            Write-Host "==================== Ping Summary ====================" -ForegroundColor Cyan
            Write-Host "Started : $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))"
            Write-Host "Ended : $($endTime.ToString('yyyy-MM-dd HH:mm:ss'))"
            Write-Host "Duration: $($duration.ToString('hh\:mm\:ss')) Sequences: $iCount"
            Write-Host ""

            foreach ($item in $pingHistory.Values) {
                $sent = $item.SuccessCount + $item.FailCount
                $successRate = if ($sent -gt 0) { [math]::Round(($item.SuccessCount / $sent) * 100, 1) } else { 0 }
                $rateColor = if ($successRate -eq 100) { 'Green' } elseif ($successRate -eq 0) { 'Red' } else { 'Yellow' }

                Write-Host ("{0,-16}" -f $item.IPAddress) -NoNewline
                Write-Host ("Sent:{0,-5} " -f $sent) -NoNewline
                Write-Host "Success:" -NoNewline
                Write-Host ("{0,-5} " -f $item.SuccessCount) -ForegroundColor Green -NoNewline
                Write-Host "Failed:" -NoNewline
                Write-Host ("{0,-5} " -f $item.FailCount) -ForegroundColor Red -NoNewline
                Write-Host ("Rate:{0,6}% " -f $successRate) -ForegroundColor $rateColor -NoNewline
                Write-Host ("DownTime:{0}s" -f [math]::Round($item.DownTime, 2))
            }
            Write-Host "========================================================" -ForegroundColor Cyan
        }
    }
    }

}