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
        }
    }
}