Public/system/Show-SystemMonitor.ps1

#Requires -Version 5.1

function Show-SystemMonitor {
    <#
    .SYNOPSIS
        Displays an interactive real-time system monitor inspired by htop
 
    .DESCRIPTION
        Renders a full-screen terminal UI showing per-core CPU usage bars, memory
        and page file utilization, and a sortable process list refreshed at a
        configurable interval. Designed for use over SSH, remoting sessions, or
        any terminal where Task Manager is not available. Press Q to quit, or use
        C/M/P/N keys to change the sort column interactively.
 
    .PARAMETER RefreshInterval
        Number of seconds between display updates. Valid range is 1 to 60.
        Defaults to 2 seconds.
 
    .PARAMETER ProcessCount
        Maximum number of processes to display. Valid range is 5 to 100.
        Defaults to 25.
 
    .PARAMETER NoColor
        Disables ANSI color output for terminals that do not support escape sequences.
 
    .EXAMPLE
        Show-SystemMonitor
 
        Launches the monitor with default settings (2-second refresh, top 25 processes).
 
    .EXAMPLE
        Show-SystemMonitor -RefreshInterval 5 -ProcessCount 40
 
        Refreshes every 5 seconds and shows the top 40 processes.
 
    .EXAMPLE
        Show-SystemMonitor -NoColor
 
        Launches without color for terminals that do not support ANSI escape sequences.
 
    .OUTPUTS
        None. This function renders an interactive TUI and does not produce pipeline output.
 
    .NOTES
        Author: Franck SALLET
        Version: 1.3.0
        Last Modified: 2026-04-06
        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/powershell/module/cimcmdlets/get-ciminstance
    #>

    [CmdletBinding()]
    # Variables defined in begin{} are used in process{} via string interpolation "${var}"
    # and [Console] method calls — PSScriptAnalyzer cannot track cross-block or interpolation usage
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    param(
        [Parameter()]
        [ValidateRange(1, 60)]
        [int]$RefreshInterval = 2,

        [Parameter()]
        [ValidateRange(5, 100)]
        [int]$ProcessCount = 25,

        [Parameter()]
        [switch]$NoColor
    )

    begin {
        # ---- Console check ----
        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
        }

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

        # Returns a threshold-based fg color for percentage values (green / yellow / red)
        function Get-ColorCode {
            param([int]$Percent)
            if (-not $script:useColor) {
                return '' 
            }
            if ($Percent -gt 80) {
                return "$script:esc[91m" 
            }   # bright red
            if ($Percent -gt 60) {
                return "$script:esc[93m" 
            }   # bright yellow
            return "$script:esc[92m"                             # bright green
        }

        # Returns a color for section labels (CPU / Mem / Swp) that reflects current load.
        # Same thresholds as Get-ColorCode but returns cyan at normal load instead of green
        # so labels are visually distinct from bar fill characters.
        function Get-LabelColor {
            param([int]$Percent)
            if (-not $script:useColor) {
                return '' 
            }
            if ($Percent -gt 80) {
                return "$script:esc[91m" 
            }   # bright red — critical
            if ($Percent -gt 60) {
                return "$script:esc[93m" 
            }   # bright yellow — warning
            return "$script:esc[96m"                             # cyan — normal
        }

        # Strips all ANSI CSI SGR sequences before measuring visual length.
        # Regex is defined inline to avoid scope-capture issues with nested functions.
        function Get-VisualWidth {
            param([string]$Text)
            ($Text -replace "$([char]27)\[\d+(?:;\d+)*m", '').Length
        }

        # Pads a string (which may contain ANSI escapes) to a target visual width.
        # Non-approved verb is intentional — private helper, never exported.
        function ConvertTo-PaddedLine {
            param([string]$Text, [int]$TargetWidth)
            $visual = Get-VisualWidth -Text $Text
            $needed = $TargetWidth - $visual
            if ($needed -gt 0) {
                return $Text + [string]::new(' ', $needed) 
            }
            return $Text
        }

        # ---- Static ANSI codes ----
        $dim = if ($useColor) {
            "$esc[90m" 
        } else {
            '' 
        }
        $reset = if ($useColor) {
            "$esc[0m"  
        } else {
            '' 
        }
        $bold = if ($useColor) {
            "$esc[1m"  
        } else {
            '' 
        }
        $cyan = if ($useColor) {
            "$esc[96m" 
        } else {
            '' 
        }
        $white = if ($useColor) {
            "$esc[97m" 
        } else {
            '' 
        }
        $underline = if ($useColor) {
            "$esc[4m"  
        } else {
            '' 
        }
        $yellow = if ($useColor) {
            "$esc[93m" 
        } else {
            '' 
        }   # uptime, refresh value
        $magenta = if ($useColor) {
            "$esc[95m" 
        } else {
            '' 
        }   # top CPU consumer name
        $bgDimRow = if ($useColor) {
            "$esc[48;5;235m" 
        } else {
            '' 
        }  # zebra stripe background
        $fgHot = if ($useColor) {
            "$esc[97m$esc[1m" 
        } else {
            '' 
        } # bright white bold — heavy process

        # ---- Bar rendering ----
        function Format-Bar {
            param([int]$Percent, [int]$Width)
            if ($Percent -lt 0) {
                $Percent = 0   
            }
            if ($Percent -gt 100) {
                $Percent = 100 
            }
            $filled = [math]::Max(0, [math]::Round($Width * $Percent / 100))
            $empty = $Width - $filled
            $color = Get-ColorCode -Percent $Percent
            $filledStr = [string]::new([char]0x2588, $filled)
            $emptyStr = [string]::new([char]0x2591, $empty)
            "${color}${filledStr}$script:dim${emptyStr}$script:reset"
        }

        function Format-Size {
            param([double]$SizeKB)
            if ($SizeKB -ge 1048576) {
                return '{0:N1}G' -f ($SizeKB / 1048576) 
            }
            if ($SizeKB -ge 1024) {
                return '{0:N0}M' -f ($SizeKB / 1024)    
            }
            return '{0:N0}K' -f $SizeKB
        }

        # Renders a colored "used / total" ratio — color driven by usage percent
        function Format-MemRatio {
            param([string]$Used, [string]$Total, [int]$Percent)
            $color = Get-LabelColor -Percent $Percent
            "${color}${Used}$script:reset $script:dim/$script:reset $script:white${Total}$script:reset"
        }

        $sortMode = 'CPU'
        $running = $true
    }

    process {
        if ($Host.Name -eq 'Windows PowerShell ISE Host') {
            return 
        }

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

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

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

                # ============================================================
                # DATA GATHERING
                # ============================================================
                $os = Get-CimInstance -ClassName 'Win32_OperatingSystem' -ErrorAction SilentlyContinue

                # Guard against WMI failure — skip frame instead of crashing
                if (-not $os) {
                    Start-Sleep -Seconds 1
                    continue
                }

                $cpuCores = @(
                    Get-CimInstance -ClassName 'Win32_PerfFormattedData_PerfOS_Processor' -ErrorAction SilentlyContinue |
                        Where-Object { $_.Name -ne '_Total' } |
                        Sort-Object { [int]$_.Name }
                )

                $cpuTotal = Get-CimInstance -ClassName 'Win32_PerfFormattedData_PerfOS_Processor' -ErrorAction SilentlyContinue |
                    Where-Object { $_.Name -eq '_Total' }

                $processes = @(
                    Get-CimInstance -ClassName 'Win32_PerfFormattedData_PerfProc_Process' -ErrorAction SilentlyContinue |
                        Where-Object { $_.Name -ne '_Total' -and $_.Name -ne 'Idle' -and $_.IDProcess -ne 0 }
                )

                $coreCount = [math]::Max(1, $cpuCores.Count)
                $width = [math]::Max(80, [Console]::WindowWidth)
                $height = [math]::Max(24, [Console]::WindowHeight)

                # Memory (KB)
                $totalMemKB = $os.TotalVisibleMemorySize
                $freeMemKB = $os.FreePhysicalMemory
                $usedMemKB = $totalMemKB - $freeMemKB
                $memPercent = [math]::Round(($usedMemKB / $totalMemKB) * 100)

                # Page file (KB)
                $totalPageKB = $os.SizeStoredInPagingFiles
                $freePageKB = $os.FreeSpaceInPagingFiles
                $usedPageKB = $totalPageKB - $freePageKB
                $pagePercent = if ($totalPageKB -gt 0) {
                    [math]::Round(($usedPageKB / $totalPageKB) * 100) 
                } else {
                    0 
                }

                # Uptime
                $uptime = (Get-Date) - $os.LastBootUpTime
                $uptimeStr = '{0}d {1:D2}:{2:D2}:{3:D2}' -f $uptime.Days, $uptime.Hours, $uptime.Minutes, $uptime.Seconds

                # Total CPU %
                $totalCpuPercent = if ($cpuTotal) {
                    [int]$cpuTotal.PercentProcessorTime 
                } else {
                    0 
                }

                # Process list
                $procList = foreach ($proc in $processes) {
                    $cpuPct = [math]::Round($proc.PercentProcessorTime / $coreCount, 1)
                    $memMB = [math]::Round($proc.WorkingSetPrivate / 1MB, 1)
                    $cleanName = $proc.Name -replace '#\d+$', ''
                    [PSCustomObject]@{
                        PID   = $proc.IDProcess
                        CPU   = $cpuPct
                        MemMB = $memMB
                        Name  = $cleanName
                    }
                }

                $sortedProcs = switch ($sortMode) {
                    'Memory' {
                        $procList | Sort-Object -Property 'MemMB' -Descending 
                    }
                    'PID' {
                        $procList | Sort-Object -Property 'PID'               
                    }
                    'Name' {
                        $procList | Sort-Object -Property 'Name'              
                    }
                    default {
                        $procList | Sort-Object -Property 'CPU' -Descending  
                    }
                }
                $topProcs = @($sortedProcs | Select-Object -First $ProcessCount)

                # ============================================================
                # FRAME RENDERING — single buffer, single Console::Write
                # ============================================================
                $lines = [System.Collections.Generic.List[string]]::new(64)
                $separator = [string]::new([char]0x2500, $width - 4)

                # ---- Header ----
                # Hostname: bold white | Uptime: yellow | Timestamp: dimmed
                $timeStr = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                $procCount = $processes.Count
                $lines.Add(" ${bold}${cyan}Show-SystemMonitor${reset} ${dim}-${reset} ${bold}${white}${env:COMPUTERNAME}${reset} ${dim}|${reset} Up: ${yellow}${uptimeStr}${reset} ${dim}|${reset} ${dim}${timeStr}${reset} ${dim}|${reset} Procs: ${white}${procCount}${reset}")
                $lines.Add(" ${dim}${separator}${reset}")

                # ---- Summary bars (CPU / Mem / Swap) ----
                # Label color reflects current load — one Get-LabelColor call per resource, negligible cost
                $barWidth = [math]::Min(40, $width - 30)

                $cpuLabelColor = Get-LabelColor -Percent $totalCpuPercent
                $cpuBar = Format-Bar -Percent $totalCpuPercent -Width $barWidth
                $cpuPctStr = '{0,5:N1}' -f $totalCpuPercent
                $lines.Add(" ${cpuLabelColor}${bold}CPU${reset} [${cpuBar}] ${cpuPctStr}% Cores: ${white}${coreCount}${reset}")

                $memLabelColor = Get-LabelColor -Percent $memPercent
                $memBar = Format-Bar -Percent $memPercent -Width $barWidth
                $memPctStr = '{0,5:N1}' -f $memPercent
                $memRatio = Format-MemRatio -Used (Format-Size $usedMemKB) -Total (Format-Size $totalMemKB) -Percent $memPercent
                $lines.Add(" ${memLabelColor}${bold}Mem${reset} [${memBar}] ${memPctStr}% ${memRatio}")

                $pageLabelColor = Get-LabelColor -Percent $pagePercent
                $pageBar = Format-Bar -Percent $pagePercent -Width $barWidth
                $pagePctStr = '{0,5:N1}' -f $pagePercent
                $pageRatio = Format-MemRatio -Used (Format-Size $usedPageKB) -Total (Format-Size $totalPageKB) -Percent $pagePercent
                $lines.Add(" ${pageLabelColor}${bold}Swp${reset} [${pageBar}] ${pagePctStr}% ${pageRatio}")
                $lines.Add('')

                # ---- Per-core CPU bars (2 columns) ----
                # Core number takes the same color as its fill bar for instant visual scanning
                $coreBarWidth = [math]::Min(20, [math]::Floor(($width - 30) / 2))
                $coreColVisualWidth = $coreBarWidth + 14

                for ($i = 0; $i -lt $cpuCores.Count; $i += 2) {
                    $pct = [int]$cpuCores[$i].PercentProcessorTime
                    $bar = Format-Bar -Percent $pct -Width $coreBarWidth
                    $coreNumColor = Get-ColorCode -Percent $pct
                    $coreLabel = '{0,3}' -f $cpuCores[$i].Name
                    $pctStr = '{0,3}' -f $pct
                    $leftCol = "${coreNumColor}${coreLabel}${reset} [${bar}] ${pctStr}%"

                    if ($i + 1 -lt $cpuCores.Count) {
                        $leftPadded = ConvertTo-PaddedLine -Text $leftCol -TargetWidth $coreColVisualWidth
                        $pct2 = [int]$cpuCores[$i + 1].PercentProcessorTime
                        $bar2 = Format-Bar -Percent $pct2 -Width $coreBarWidth
                        $coreNumColor2 = Get-ColorCode -Percent $pct2
                        $coreLabel2 = '{0,3}' -f $cpuCores[$i + 1].Name
                        $pct2Str = '{0,3}' -f $pct2
                        $rightCol = "${coreNumColor2}${coreLabel2}${reset} [${bar2}] ${pct2Str}%"
                        $lines.Add(" ${leftPadded} ${rightCol}")
                    } else {
                        $lines.Add(" ${leftCol}")
                    }
                }
                $lines.Add(" ${dim}${separator}${reset}")

                # ---- Process table header ----
                # Active sort column highlighted in cyan + underline; inactive columns dimmed
                $pidH = if ($sortMode -eq 'PID') {
                    "${cyan}${underline}PID${reset}"     
                } else {
                    "${dim}PID${reset}"     
                }
                $cpuH = if ($sortMode -eq 'CPU') {
                    "${cyan}${underline}CPU%${reset}"    
                } else {
                    "${dim}CPU%${reset}"    
                }
                $memH = if ($sortMode -eq 'Memory') {
                    "${cyan}${underline}MEM(MB)${reset}" 
                } else {
                    "${dim}MEM(MB)${reset}" 
                }
                $nameH = if ($sortMode -eq 'Name') {
                    "${cyan}${underline}Name${reset}"    
                } else {
                    "${dim}Name${reset}"    
                }
                $lines.Add(" ${bold} ${pidH} ${cpuH} ${memH} ${nameH}${reset}")

                # ---- Process rows ----
                # Reserve 3 lines for: blank + separator + footer
                $availableRows = $height - $lines.Count - 3
                $displayCount = [math]::Min($topProcs.Count, [math]::Max(5, $availableRows))

                for ($i = 0; $i -lt $displayCount; $i++) {
                    $p = $topProcs[$i]
                    $cpuColor = Get-ColorCode -Percent ([math]::Min(100, $p.CPU * 2))
                    $pidStr = '{0,7}' -f $p.PID
                    $cpuStr = '{0,7:N1}' -f $p.CPU
                    $memStr = '{0,9:N1}' -f $p.MemMB

                    # Zebra striping: odd rows get a barely-visible dark background
                    $rowBg = if ($useColor -and ($i % 2 -eq 1)) {
                        $bgDimRow 
                    } else {
                        '' 
                    }
                    $rowReset = if ($useColor -and ($i % 2 -eq 1)) {
                        $reset    
                    } else {
                        '' 
                    }

                    # Process name coloring:
                    # rank 0 (top consumer) → magenta
                    # CPU > 50% → bright white bold
                    # otherwise → normal white
                    $nameColor = if ($i -eq 0 -and $p.CPU -gt 0) {
                        $magenta 
                    } elseif ($p.CPU -gt 50) {
                        $fgHot   
                    } else {
                        $white   
                    }

                    $lines.Add("${rowBg} ${pidStr} ${cpuColor}${cpuStr}${reset}${rowBg} ${memStr} ${nameColor}$($p.Name)${rowReset}${reset}")
                }

                # ---- Footer ----
                # Hotkey letters in cyan; active sort value in cyan bold
                $lines.Add('')
                $lines.Add(" ${dim}${separator}${reset}")
                $lines.Add(" ${bold}[${cyan}Q${reset}${bold}]${reset}uit ${bold}[${cyan}C${reset}${bold}]${reset}PU ${bold}[${cyan}M${reset}${bold}]${reset}em ${bold}[${cyan}P${reset}${bold}]${reset}ID ${bold}[${cyan}N${reset}${bold}]${reset}ame ${dim}|${reset} Refresh: ${yellow}${RefreshInterval}s${reset} ${dim}|${reset} Sort: ${cyan}${bold}${sortMode}${reset}")

                # ============================================================
                # SINGLE WRITE — move cursor home, write all lines, erase tail
                # ============================================================
                $frame = [System.Text.StringBuilder]::new($lines.Count * ($width + 20))

                # Move cursor to top-left without clearing (avoids flash)
                [void]$frame.Append("$esc[H")

                foreach ($line in $lines) {
                    $padded = ConvertTo-PaddedLine -Text $line -TargetWidth $width
                    [void]$frame.AppendLine($padded)
                }

                # Erase remaining rows — ESC[2K clears the current line,
                # AppendLine advances the cursor to the next row
                $remainingRows = $height - $lines.Count
                for ($r = 0; $r -lt $remainingRows; $r++) {
                    [void]$frame.AppendLine("$esc[2K")
                }

                [Console]::Write($frame.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) {
                        $key = [Console]::ReadKey($true)

                        # Ctrl+C — checked first so it always wins
                        if ($key.Key -eq 'C' -and ($key.Modifiers -band [ConsoleModifiers]::Control)) {
                            $running = $false
                            break
                        }

                        if ($key.Key -eq 'Q' -or $key.Key -eq 'Escape') {
                            $running = $false 
                        } elseif ($key.Key -eq 'C') {
                            $sortMode = 'CPU'    
                        } elseif ($key.Key -eq 'M') {
                            $sortMode = 'Memory' 
                        } elseif ($key.Key -eq 'P') {
                            $sortMode = 'PID'    
                        } elseif ($key.Key -eq 'N') {
                            $sortMode = 'Name'   
                        }

                        if (-not $running) {
                            break 
                        }
                    }
                    Start-Sleep -Milliseconds 50
                }
            }
        } finally {
            [Console]::CursorVisible = $previousCursorVisible
            [Console]::TreatControlCAsInput = $previousCtrlC
            [Console]::Clear()
            Write-Information -MessageData 'System monitor stopped.' -InformationAction Continue
        }
    }
}