From 8ea6bdfd18219c5c5220ec736e43e1f14da568fa Mon Sep 17 00:00:00 2001 From: Kalcinator <141585771+Kalcinator@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:46:46 +0200 Subject: [PATCH] Improve ping failure handling and docs --- README.md | 14 +- advanced-ping-monitor.ps1 | 242 +++++++++++++++++--------------- tests/Get-PingFailure.Tests.ps1 | 88 ++++++++++++ 3 files changed, 227 insertions(+), 117 deletions(-) create mode 100644 tests/Get-PingFailure.Tests.ps1 diff --git a/README.md b/README.md index 44085d2..b541a9f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ An intelligent, aesthetic, and robust network latency monitor with automatic fai * `E5` (659 Hz) for critical latency warnings. * `D6` (1175 Hz) for packet loss errors. * A C-Major arpeggio (`C6-E6-G6`) for a clear "Connection Restored!" notification. -* **📊 Persistent Statistics:** Displays comprehensive session statistics (Total, Lost, Average, Loss Rate) every 10 successful prings, ensuring an accurate long-term view of your connection's quality. +* **📊 Persistent Statistics:** Displays comprehensive session statistics (Total, Lost, Average, Loss Rate) every 10 successful pings, ensuring an accurate long-term view of your connection's quality. * **🎨 Aesthetic Interface:** Uses a customizable rainbow color cycle for successful pings to provide a visually pleasing and informative display. --- @@ -28,7 +28,7 @@ An intelligent, aesthetic, and robust network latency monitor with automatic fai * **PowerShell 7.0+** (Required for optimal color and syntax compatibility) #### Installation -1. Download the `ping-monitor.ps1` script to a convenient location on your computer (e.g., `C:\Scripts\`). +1. Download the `advanced-ping-monitor.ps1` script to a convenient location on your computer (e.g., `C:\Scripts\`). 2. Open a PowerShell 7 terminal. 3. Navigate to the directory where you saved the script: ```powershell @@ -43,24 +43,24 @@ An intelligent, aesthetic, and robust network latency monitor with automatic fai To run the script with its default settings (monitoring a FFXIV server, fallback to Google DNS), simply execute it: ```powershell -.\ping-monitor.ps1 +.\advanced-ping-monitor.ps1 ``` #### Custom Examples * **Monitor a different target and use a different fallback:** ```powershell - .\ping-monitor.ps1 -PrimaryTarget "www.google.com" -FallbackTarget "1.1.1.1" + .\advanced-ping-monitor.ps1 -PrimaryTarget "www.google.com" -FallbackTarget "1.1.1.1" ``` * **Set a more aggressive latency threshold and mute all sounds:** ```powershell - .\ping-monitor.ps1 -CriticalMs 75 -Mute + .\advanced-ping-monitor.ps1 -CriticalMs 75 -Mute ``` * **Ping twice per second and increase the history size for the average calculation:** ```powershell - .\ping-monitor.ps1 -IntervalMs 500 -HistorySize 60 + .\advanced-ping-monitor.ps1 -IntervalMs 500 -HistorySize 60 ``` --- @@ -74,7 +74,7 @@ To have the monitor launch automatically when you log into Windows, create a sho 3. In the "Type the location of the item" field, paste the following command. **Make sure to adjust the path to your script file!** ``` - "C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo -ExecutionPolicy Bypass -File "C:\Scripts\ping-monitor.ps1" + "C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo -ExecutionPolicy Bypass -File "C:\Scripts\advanced-ping-monitor.ps1" ``` 4. Click `Next`, give the shortcut a name (e.g., "Ping Monitor"), and click `Finish`. diff --git a/advanced-ping-monitor.ps1 b/advanced-ping-monitor.ps1 index 96de2bb..51807cc 100644 --- a/advanced-ping-monitor.ps1 +++ b/advanced-ping-monitor.ps1 @@ -40,14 +40,14 @@ .PARAMETER Mute If specified, disables all audio alerts. -.EXAMPLE - PS C:\> .\ping-monitor.ps1 +.EXAMPLE + PS C:\> .\advanced-ping-monitor.ps1 Launches the script with default settings. It monitors the FFXIV server (80.239.145.6) and uses Google DNS (8.8.8.8) as its fallback target. -.EXAMPLE - PS C:\> .\ping-monitor.ps1 -PrimaryTarget "www.google.com" -FallbackTarget "1.1.1.1" +.EXAMPLE + PS C:\> .\advanced-ping-monitor.ps1 -PrimaryTarget "www.google.com" -FallbackTarget "1.1.1.1" Monitors "www.google.com" as the primary target and uses Cloudflare DNS (1.1.1.1) in case of failure. @@ -68,9 +68,9 @@ 2. In the folder that opens, right-click > New > Shortcut. 3. In the "Type the location of the item" field, paste the following command: - "C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo -ExecutionPolicy Bypass -File "C:\Path\To\Your\ping-monitor.ps1" - - (Make sure to adjust the path to your ping-monitor.ps1 file if necessary) + "C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo -ExecutionPolicy Bypass -File "C:\Path\To\Your\advanced-ping-monitor.ps1" + + (Make sure to adjust the path to your advanced-ping-monitor.ps1 file if necessary) ----------------------------------------------------------------------------- .LINK @@ -122,83 +122,103 @@ function Get-NextColor { return $color } -# Parses a PingReply or ErrorRecord to create a structured and comprehensive PingFailure object. -function Get-PingFailure { - param( - [System.Net.NetworkInformation.PingReply]$Reply, - [System.Management.Automation.ErrorRecord]$ErrorRecord - ) - - if ($ErrorRecord) { - return [PingFailure]::new([PingFailureType]::NetworkError, "Exception", "Network error (Exception)") - } - - if ($Reply) { - $status = $Reply.Status.ToString() - $type = [PingFailureType]::Unknown # Default type - $message = "" - - switch ($Reply.Status) { - 'TimedOut' { - $type = [PingFailureType]::TimedOut; $message = "Request timed out" - } - 'DestinationHostUnreachable' { - $type = [PingFailureType]::HostUnreachable; $message = "Destination host unreachable" - } - 'DestinationNetworkUnreachable' { - $type = [PingFailureType]::HostUnreachable; $message = "Destination network unreachable" - } - 'DestinationProhibited' { - $type = [PingFailureType]::HostUnreachable; $message = "Destination access prohibited (firewall)" - } - 'DestinationPortUnreachable' { - $type = [PingFailureType]::HostUnreachable; $message = "Destination port unreachable" - } - 'BadDestination' { - $type = [PingFailureType]::HostUnreachable; $message = "Invalid destination address" - } - 'BadRoute' { - $type = [PingFailureType]::NetworkError; $message = "Bad network route" - } - 'TtlExpired' { - $type = [PingFailureType]::NetworkError; $message = "Packet TTL (Time-To-Live) expired in transit" - } - 'TtlReassemblyTimeExceeded' { - $type = [PingFailureType]::NetworkError; $message = "Packet reassembly time (TTL) exceeded" - } - 'PacketTooBig' { - $type = [PingFailureType]::NetworkError; $message = "Packet too big (MTU issue)" - } - 'BadOption' { - $type = [PingFailureType]::NetworkError; $message = "Invalid packet option" - } - 'ParameterProblem' { - $type = [PingFailureType]::NetworkError; $message = "Parameter problem in IP header" - } - 'BadHeader' { - $type = [PingFailureType]::NetworkError; $message = "Invalid packet header" - } - 'HardwareError' { - $type = [PingFailureType]::NetworkError; $message = "Hardware error on the network" - } - 'NoResources' { - $type = [PingFailureType]::NetworkError; $message = "Insufficient system resources (local)" - } - 'SourceQuench' { - $type = [PingFailureType]::NetworkError; $message = "Network overload / Congestion" - } - 'IcmpError' { - $type = [PingFailureType]::NetworkError; $message = "ICMP protocol error" - } - default { - $type = [PingFailureType]::Unknown; $message = "Unlisted error: $status" - } - } - return [PingFailure]::new($type, $status, $message) - } - - return [PingFailure]::new([PingFailureType]::Unknown, "NoReply", "Indeterminate error") -} +# Parses a PingReply or ErrorRecord to create a structured and comprehensive PingFailure object. +function Get-PingFailure { + [CmdletBinding(DefaultParameterSetName = 'FromReply')] + param( + [Parameter(ParameterSetName = 'FromReply')] + [System.Net.NetworkInformation.PingReply]$Reply, + + [Parameter(ParameterSetName = 'FromError', Mandatory = $true)] + [System.Management.Automation.ErrorRecord]$ErrorRecord, + + [Parameter(ParameterSetName = 'FromStatus', Mandatory = $true)] + [System.Net.NetworkInformation.IPStatus]$Status + ) + + $resolveFromStatus = { + param([System.Net.NetworkInformation.IPStatus]$statusValue) + + $status = $statusValue.ToString() + $type = [PingFailureType]::Unknown + $message = "" + + switch ($status) { + 'TimedOut' { + $type = [PingFailureType]::TimedOut; $message = "Request timed out" + } + 'DestinationHostUnreachable' { + $type = [PingFailureType]::HostUnreachable; $message = "Destination host unreachable" + } + 'DestinationNetworkUnreachable' { + $type = [PingFailureType]::HostUnreachable; $message = "Destination network unreachable" + } + 'DestinationProhibited' { + $type = [PingFailureType]::HostUnreachable; $message = "Destination access prohibited (firewall)" + } + 'DestinationPortUnreachable' { + $type = [PingFailureType]::HostUnreachable; $message = "Destination port unreachable" + } + 'BadDestination' { + $type = [PingFailureType]::HostUnreachable; $message = "Invalid destination address" + } + 'BadRoute' { + $type = [PingFailureType]::NetworkError; $message = "Bad network route" + } + 'TtlExpired' { + $type = [PingFailureType]::NetworkError; $message = "Packet TTL (Time-To-Live) expired in transit" + } + 'TtlReassemblyTimeExceeded' { + $type = [PingFailureType]::NetworkError; $message = "Packet reassembly time (TTL) exceeded" + } + 'PacketTooBig' { + $type = [PingFailureType]::NetworkError; $message = "Packet too big (MTU issue)" + } + 'BadOption' { + $type = [PingFailureType]::NetworkError; $message = "Invalid packet option" + } + 'ParameterProblem' { + $type = [PingFailureType]::NetworkError; $message = "Parameter problem in IP header" + } + 'BadHeader' { + $type = [PingFailureType]::NetworkError; $message = "Invalid packet header" + } + 'HardwareError' { + $type = [PingFailureType]::NetworkError; $message = "Hardware error on the network" + } + 'NoResources' { + $type = [PingFailureType]::NetworkError; $message = "Insufficient system resources (local)" + } + 'SourceQuench' { + $type = [PingFailureType]::NetworkError; $message = "Network overload / Congestion" + } + 'IcmpError' { + $type = [PingFailureType]::NetworkError; $message = "ICMP protocol error" + } + default { + $type = [PingFailureType]::Unknown; $message = "Unlisted error: $status" + } + } + + return [PingFailure]::new($type, $status, $message) + } + + switch ($PSCmdlet.ParameterSetName) { + 'FromReply' { + if ($null -ne $Reply) { + return & $resolveFromStatus $Reply.Status + } + } + 'FromStatus' { + return & $resolveFromStatus $Status + } + 'FromError' { + return [PingFailure]::new([PingFailureType]::NetworkError, "Exception", "Network error (Exception)") + } + } + + return [PingFailure]::new([PingFailureType]::Unknown, "NoReply", "Indeterminate error") +} function Show-PingFailure { param([string]$DisplayTarget, [PingFailure]$Failure, [bool]$MuteBeep = $false) @@ -284,8 +304,9 @@ try { Clear-Host; Wait-NetworkConnection -State $state Clear-Host; Write-Host "Sending 'Ping' requests to $($state.PrimaryTarget) (fallback: $($state.FallbackTarget)):" -ForegroundColor White; Write-Host "" - while ($true) { - $stopwatch.Restart(); $statistics.Total++ + while ($true) { + $stopwatch.Restart(); $statistics.Total++ + $reply = $null if ($state.IsOnFallback) { if ($state.PrimaryCheckJob -and $state.PrimaryCheckJob.State -eq 'Completed') { @@ -308,30 +329,31 @@ try { } } - try { - $reply = $ping.Send($state.CurrentTarget, $IntervalMs, $payload) - if ($reply.Status -eq 'Success') { - if ($state.IsQuietMode) { Invoke-ReconnectionAlert -MuteBeep $Mute } - - $state.IsQuietMode = $false; $state.ConsecutiveLosses = 0 - - $latency = $reply.RoundtripTime; $history.Enqueue($latency); $historySum += $latency - Show-PingSuccess -DisplayTarget $state.CurrentTarget -Latency $latency -TTL ($reply.Options?.Ttl ?? 0) - } else { throw } - } catch { - $statistics.Lost++; $state.ConsecutiveLosses++ - if (-not $state.IsOnFallback -and $state.ConsecutiveLosses -eq 1) { - Write-Host "`nPrimary target lost. Failing over to $($state.FallbackTarget)..." -ForegroundColor DarkYellow - $state.IsOnFallback = $true; $state.CurrentTarget = $state.FallbackTarget; $history.Clear(); $historySum = 0L - } - if ($state.ConsecutiveLosses -ge $state.MaxConsecutiveLosses) { $state.IsQuietMode = $true } - - if ($state.IsQuietMode) { - Update-QuietFailureStatus -ConsecutiveCount $state.ConsecutiveLosses - } else { - Show-PingFailure -DisplayTarget $state.CurrentTarget -Failure (Get-PingFailure -Reply $reply -ErrorRecord $_) -MuteBeep $Mute - } - } + try { + $reply = $ping.Send($state.CurrentTarget, $IntervalMs, $payload) + if ($reply.Status -eq 'Success') { + if ($state.IsQuietMode) { Invoke-ReconnectionAlert -MuteBeep $Mute } + + $state.IsQuietMode = $false; $state.ConsecutiveLosses = 0 + + $latency = $reply.RoundtripTime; $history.Enqueue($latency); $historySum += $latency + Show-PingSuccess -DisplayTarget $state.CurrentTarget -Latency $latency -TTL ($reply.Options?.Ttl ?? 0) + } else { throw } + } catch { + $statistics.Lost++; $state.ConsecutiveLosses++ + if (-not $state.IsOnFallback -and $state.ConsecutiveLosses -eq 1) { + Write-Host "`nPrimary target lost. Failing over to $($state.FallbackTarget)..." -ForegroundColor DarkYellow + $state.IsOnFallback = $true; $state.CurrentTarget = $state.FallbackTarget; $history.Clear(); $historySum = 0L + } + if ($state.ConsecutiveLosses -ge $state.MaxConsecutiveLosses) { $state.IsQuietMode = $true } + + if ($state.IsQuietMode) { + Update-QuietFailureStatus -ConsecutiveCount $state.ConsecutiveLosses + } else { + $failure = if ($null -ne $reply) { Get-PingFailure -Reply $reply } else { Get-PingFailure -ErrorRecord $_ } + Show-PingFailure -DisplayTarget $state.CurrentTarget -Failure $failure -MuteBeep $Mute + } + } while ($history.Count -gt $HistorySize) { $historySum -= $history.Dequeue() } diff --git a/tests/Get-PingFailure.Tests.ps1 b/tests/Get-PingFailure.Tests.ps1 new file mode 100644 index 0000000..13cc3cc --- /dev/null +++ b/tests/Get-PingFailure.Tests.ps1 @@ -0,0 +1,88 @@ +$ErrorActionPreference = 'Stop' + +BeforeAll { + $scriptPath = Join-Path $PSScriptRoot '..' 'advanced-ping-monitor.ps1' + + $null = [System.IO.File]::Exists($scriptPath) -or throw "Unable to locate script at $scriptPath." + + $tokens = $null + $errors = $null + $scriptAst = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$errors) + + function Import-AstDefinition { + param( + [Parameter(Mandatory = $true)] + [System.Management.Automation.Language.Ast]$Ast, + + [Parameter(Mandatory = $true)] + [type]$AstType, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $definition = $Ast.Find({ param($node) ($node -is $AstType) -and $node.Name -eq $Name }, $true) + if (-not $definition) { + throw "Unable to locate definition for '$Name'." + } + + Invoke-Expression $definition.Extent.Text + } + + Import-AstDefinition -Ast $scriptAst -AstType ([System.Management.Automation.Language.TypeDefinitionAst]) -Name 'PingFailureType' + Import-AstDefinition -Ast $scriptAst -AstType ([System.Management.Automation.Language.TypeDefinitionAst]) -Name 'PingFailure' + Import-AstDefinition -Ast $scriptAst -AstType ([System.Management.Automation.Language.FunctionDefinitionAst]) -Name 'Get-PingFailure' + + Remove-Item Function:Import-AstDefinition -ErrorAction SilentlyContinue +} + +Describe 'Get-PingFailure' { + It 'maps timed out status to the TimedOut failure type' { + $failure = Get-PingFailure -Status ([System.Net.NetworkInformation.IPStatus]::TimedOut) + + $failure.Type | Should -Be ([PingFailureType]::TimedOut) + $failure.OriginalStatus | Should -Be 'TimedOut' + $failure.DisplayMessage | Should -Be 'Request timed out' + } + + It 'classifies host unreachable statuses correctly' { + $failure = Get-PingFailure -Status ([System.Net.NetworkInformation.IPStatus]::DestinationHostUnreachable) + + $failure.Type | Should -Be ([PingFailureType]::HostUnreachable) + $failure.DisplayMessage | Should -Be 'Destination host unreachable' + } + + It 'flags network level issues as network errors' { + $failure = Get-PingFailure -Status ([System.Net.NetworkInformation.IPStatus]::BadRoute) + + $failure.Type | Should -Be ([PingFailureType]::NetworkError) + $failure.DisplayMessage | Should -Be 'Bad network route' + } + + It 'falls back to an unknown failure for unlisted statuses' { + $failure = Get-PingFailure -Status ([System.Net.NetworkInformation.IPStatus]::Unknown) + + $failure.Type | Should -Be ([PingFailureType]::Unknown) + $failure.DisplayMessage | Should -Be 'Unlisted error: Unknown' + } + + It 'reports exceptions as network errors when no reply is available' { + try { + throw [System.Net.NetworkInformation.PingException]::new('Simulated failure') + } catch { + $failure = Get-PingFailure -ErrorRecord $_ + } + + $failure.Type | Should -Be ([PingFailureType]::NetworkError) + $failure.OriginalStatus | Should -Be 'Exception' + $failure.DisplayMessage | Should -Be 'Network error (Exception)' + } + + It 'returns an indeterminate failure when no details are supplied' { + $failure = Get-PingFailure + + $failure.Type | Should -Be ([PingFailureType]::Unknown) + $failure.OriginalStatus | Should -Be 'NoReply' + $failure.DisplayMessage | Should -Be 'Indeterminate error' + } +}