Private/Format-PingMonitorFrame.ps1

#Requires -Version 5.1

function Format-PingMonitorFrame {
    <#
        .SYNOPSIS
            Renders a single text frame for Show-PingMonitor (pure formatter, no I/O).
 
        .DESCRIPTION
            Accepts a snapshot of ping statistics and returns the complete console
            frame as a [string]. Contains no interactive or I/O calls, making it
            fully unit-testable without a live terminal.
 
        .PARAMETER StatsTable
            Hashtable keyed by hostname. Each value is a hashtable with keys:
            Sent, Received, Lost, LastMs, MinMs, MaxMs, TotalMs, Status.
 
        .PARAMETER HostList
            Ordered array of hostnames to display.
 
        .PARAMETER MaxHostLen
            Minimum column width for the HOST column (characters).
 
        .PARAMETER SortMode
            Active sort column. Valid values: Host, Status, LastMs, Loss.
 
        .PARAMETER Paused
            When $true, the (PAUSED) indicator is shown in the header.
 
        .PARAMETER ElapsedStr
            Pre-formatted elapsed time string (HH:MM:SS) to display in header/footer.
 
        .PARAMETER RefreshInterval
            Refresh interval in seconds displayed in the footer.
 
        .PARAMETER NoColor
            When set, ANSI escape sequences are suppressed.
 
        .PARAMETER TerminalHeight
            Number of terminal rows available. Rows beyond the frame content are
            erased with ESC[2K sequences appended to the returned string.
 
        .OUTPUTS
            System.String
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory = $true)]
        [hashtable]$StatsTable,

        [Parameter(Mandatory = $true)]
        [string[]]$HostList,

        [Parameter(Mandatory = $true)]
        [int]$MaxHostLen,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Host', 'Status', 'LastMs', 'Loss')]
        [string]$SortMode,

        [Parameter(Mandatory = $true)]
        [bool]$Paused,

        [Parameter(Mandatory = $true)]
        [string]$ElapsedStr,

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

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

        [Parameter(Mandatory = $false)]
        [int]$TerminalHeight = 24
    )

    $esc      = [char]27
    $useColor = -not $NoColor.IsPresent

    $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 { '' }

    $frameBuilder = [System.Text.StringBuilder]::new(4096)

    # 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 the 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}")

    # Pad remaining rows with erase sequences
    $currentLines = $frameBuilder.ToString().Split("`n").Count
    for ($r = $currentLines; $r -lt $TerminalHeight; $r++) {
        [void]$frameBuilder.AppendLine("${esc}[2K")
    }

    return $frameBuilder.ToString()
}