Build a System Health Monitor

Write a PowerShell script that collects system metrics and outputs an HTML report.

Introduction

30 Second Summary

Every computer is constantly tracking its own vital signs. CPU load, available memory, remaining disk space. When something slows down or crashes, the first thing any admin does is check these numbers.

In this project, you will build a PowerShell script that collects real-time system data from your Windows machine and generates a styled HTML health report you can open in your browser.

What You'll Build

A PowerShell script called Get-SystemHealthReport.ps1 that queries your system for OS information, memory usage, disk space, and top processes, then outputs everything as a formatted HTML report.

By the end of this project, you'll have:

  • PowerShell 7.6 installed and configured as your development shell in VS Code.
  • A working system health script that uses CIM cmdlets, functions, error handling, and the object pipeline to collect live metrics.
  • A styled HTML report displaying OS info, memory, disk space, and top processes that you can open in any browser.
  • Secret Mission: Add threshold-based alerting that highlights metrics in red when they exceed configurable warning levels.

Are there any prerequisites?

You need a Windows machine with VS Code already installed. Some programming experience (in any language) is helpful but you do not need prior PowerShell knowledge.

Before We Start

Before We Start...

Install PowerShell 7.6 and Configure VS Code

Before you can write a system health monitoring script, you need the right tools in place. PowerShell 7.6 is the current Long-Term Support release with better performance, improved error handling, and UTF-8 defaults compared to Windows PowerShell 5.1.

In this step, you will install PowerShell 7.6, set up VS Code as your PowerShell development environment, and create the project folder where your script will live.

In this step, get ready to:
  • Install PowerShell 7.6 LTS and verify it works.
  • Install and configure the VS Code PowerShell extension.
  • Create a project folder and script file for your health monitor.
Install PowerShell 7.6 LTS

PowerShell 7.6 installs side-by-side with the Windows PowerShell 5.1 already on your machine. It does not replace 5.1, so there is zero risk to your existing system.

  • Open any terminal you already have. Press the Windows key, type Command Prompt, and press Enter to open it.
  • Install PowerShell 7.6 by running this command:
winget install --id Microsoft.PowerShell --source winget

What does this command do?

winget is the Windows Package Manager built into Windows 11. The --id Microsoft.PowerShell flag tells winget exactly which package to install, and --source winget tells it to use the winget community repository.

Beginning with PowerShell 7.6.0, winget installs the MSIX package by default. The installer may prompt you to accept license agreements. Agree to proceed.

  • Once the installation completes, close Command Prompt and open a fresh one.
  • Launch PowerShell 7 by running:
pwsh
  • Verify your version by running:
$PSVersionTable.PSVersion

You should see a version number starting with 7.6 (for example, 7.6.3).

✔️ I see version 7.6 or higher

PowerShell 7.6 is ready to go. You can move on to the next substep.

ⓧ I see an older version (7.4, 7.2, etc.)

You have an older version of PowerShell 7 installed. Upgrade to the latest LTS by running:

winget upgrade --id Microsoft.PowerShell --source winget
  • After the upgrade completes, close and reopen your terminal.
  • Run pwsh and check $PSVersionTable.PSVersion again to confirm you see 7.6.

ⓧ Command not found

If pwsh is not recognized, the installer may not have added it to your PATH yet.

  • Close your current terminal completely and open a fresh Command Prompt.
  • Type pwsh and press Enter. A fresh terminal picks up PATH changes made by the installer.
  • If it still fails, try running the install command again:
winget install --id Microsoft.PowerShell --source winget
  • After reinstalling, close and reopen Command Prompt, then try pwsh once more.

Why PowerShell 7.6 instead of Windows PowerShell 5.1?

Windows PowerShell 5.1 ships with every Windows machine, but it is in maintenance mode. PowerShell 7.6 is the current Long-Term Support release with better performance, UTF-8 defaults, and modern error handling.

Best practice for new scripts in 2026 is to target PowerShell 7+. It runs side-by-side with 5.1, so nothing breaks.

Install the VS Code PowerShell extension

The PowerShell extension gives VS Code syntax highlighting, IntelliSense, and integrated debugging for .ps1 files.

  • Switch back to VS Code.
  • Open the Extensions panel by pressing Ctrl+Shift+X.
  • Type PowerShell in the search box.
  • Find the extension published by Microsoft (extension ID: ms-vscode.powershell).
  • Click Install.

Now configure VS Code to use PowerShell 7 as your default integrated terminal.

  • Open the Command Palette by pressing Ctrl+Shift+P.
  • Type Terminal: Select Default Profile and select it from the list.
  • Choose PowerShell from the profiles list. This is the PowerShell 7 profile detected by VS Code after your install.

What does selecting a default profile do?

Every new integrated terminal in VS Code now opens with pwsh (PowerShell 7) instead of the older Windows PowerShell 5.1. This means your scripts always run against the correct version.

Don't see PowerShell in the profiles list?

If the PowerShell 7 profile does not appear, VS Code may not have detected the new install yet.

  • Close VS Code completely and reopen it. VS Code scans for installed shells on startup.
  • Try the Command Palette step again after reopening.

Why isn't PowerShell 7 showing up as a terminal profile in VS Code?

Create the project folder and script file

With your environment ready, create a dedicated folder for the health monitor project.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key). It should now open as PowerShell 7.
  • Create the project folder and move into it by running these commands:
New-Item -ItemType Directory -Path C:\Scripts\HealthMonitor -Force
Set-Location C:\Scripts\HealthMonitor

What do these commands do?

New-Item -ItemType Directory creates the folder at C:\Scripts\HealthMonitor. The -Force flag means it will not error if the folder already exists.

Set-Location changes your working directory into the new folder. It is the PowerShell equivalent of cd.

  • Open this folder in VS Code by running:
code .

What does this do?

The code . command tells VS Code to open the current directory as a workspace. A new VS Code window opens with the HealthMonitor folder in the Explorer sidebar.

  • VS Code may ask if you trust the authors of the files in this folder. Click Yes, I trust the authors.
  • Create the script file by clicking the New File icon next to HEALTHMONITOR in the Explorer sidebar.
  • Name the file Get-SystemHealthReport.ps1 and press Enter.

The file opens in the editor. It is empty for now.

  • Verify your environment works by opening the integrated terminal (Ctrl+`) and running:
Write-Output "Hello from PowerShell $($PSVersionTable.PSVersion)"

You should see output like Hello from PowerShell 7.6.3 in the terminal.

Seeing a script execution error?

If you see an error about execution policy when running scripts later, you need to allow script execution for your user. Run this once in your PowerShell 7 terminal:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

This allows locally-created scripts to run while still blocking unsigned remote scripts.

Help me fix a PowerShell execution policy error in VS Code.

Your development environment is locked and loaded. Next up, you will write functions that query real system data using CIM cmdlets.

Query System Data with CIM Cmdlets

Your environment is ready. Now it is time to write the actual script that collects health data from your machine.

Windows exposes hardware and OS metrics through WMI classes. PowerShell's Get-CimInstance cmdlet lets you query those classes and get back rich objects you can filter, sort, and transform. In this step, you will build three functions that collect OS info, disk space, and top processes.

In this step, get ready to:
  • Set up the script scaffold with a version requirement and parameter block.
  • Write three data-gathering functions using CIM cmdlets and the object pipeline.
  • Test the script interactively and verify output with Format-Table.
Set up the script scaffold

Every production PowerShell script starts with a structure that declares its requirements and accepts parameters. This makes the script self-documenting and reusable.

  • Open Get-SystemHealthReport.ps1 in the VS Code editor (it should be empty) and paste the following code:
#Requires -Version 7.0

[CmdletBinding()]
param(
    [Parameter()]
    [string]$OutputPath = ".\HealthReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html"
)

$ErrorActionPreference = 'Stop'

What does this code do?

This scaffold establishes three guardrails for your script.

  • #Requires -Version 7.0 prevents the script from running on older PowerShell versions. If someone accidentally opens it in Windows PowerShell 5.1, they get a clear error instead of mysterious failures.
  • [CmdletBinding()] and the param() block turn your script into an advanced function. This gives you automatic support for -Verbose, -Debug, and other common parameters.
  • $ErrorActionPreference = 'Stop' makes all errors terminating so they can be caught by try/catch blocks. Without this, many cmdlet errors silently continue.
  • Save Get-SystemHealthReport.ps1.

You will not see any output yet. The scaffold just defines the structure that the functions will live inside.

Write the data-gathering functions

Each function queries a different CIM class (or cmdlet) and returns a custom object with human-readable properties. You will write three functions: one for OS and memory info, one for disk space, and one for the top processes by memory.

  • In Get-SystemHealthReport.ps1, place your cursor on the blank line below $ErrorActionPreference = 'Stop' and paste the Get-OSInfo function:
function Get-OSInfo {
    try {
        $os = Get-CimInstance -ClassName Win32_OperatingSystem
        [PSCustomObject]@{
            ComputerName    = $env:COMPUTERNAME
            OSName          = $os.Caption
            Version         = $os.Version
            LastBoot        = $os.LastBootUpTime
            "TotalMemory(GB)" = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
            "FreeMemory(GB)"  = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
            "MemoryUsed(%)"   = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 1)
        }
    }
    catch {
        Write-Warning "Failed to retrieve OS information: $_"
        [PSCustomObject]@{
            ComputerName    = $env:COMPUTERNAME
            OSName          = "Error retrieving data"
            Version         = "N/A"
            LastBoot        = "N/A"
            "TotalMemory(GB)" = 0
            "FreeMemory(GB)"  = 0
            "MemoryUsed(%)"   = 0
        }
    }
}

What does this function do?

Get-CimInstance -ClassName Win32_OperatingSystem queries the Windows OS class to retrieve system metadata. The Win32_OperatingSystem class reports memory in kilobytes, so dividing by 1MB (1,048,576) converts to gigabytes.

The try/catch block ensures the script does not crash if the query fails. Instead, it returns a fallback object with zeroed values so the rest of the report can still generate.

  • Save Get-SystemHealthReport.ps1.
  • Directly below the Get-OSInfo function's closing }, paste the Get-DiskInfo function:
function Get-DiskInfo {
    try {
        Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" |
            Select-Object -Property DeviceID,
                @{Label = "Size(GB)"; Expression = { [math]::Round($_.Size / 1GB, 2) }},
                @{Label = "FreeSpace(GB)"; Expression = { [math]::Round($_.FreeSpace / 1GB, 2) }},
                @{Label = "Used(%)"; Expression = { [math]::Round((($_.Size - $_.FreeSpace) / $_.Size) * 100, 1) }}
    }
    catch {
        Write-Warning "Failed to retrieve disk information: $_"
        [PSCustomObject]@{
            DeviceID       = "Error"
            "Size(GB)"     = 0
            "FreeSpace(GB)" = 0
            "Used(%)"      = 0
        }
    }
}

What are calculated properties?

The @{Label = "..."; Expression = { ... }} hashtables are called calculated properties. They let you transform raw data inline as it flows through the pipeline. Here, Win32_LogicalDisk reports disk size in bytes, so the expression divides by 1GB to produce a readable gigabyte value.

The filter DriveType=3 limits results to fixed hard disks. This excludes CD-ROMs, USB sticks, and network drives.

  • Save Get-SystemHealthReport.ps1.
  • Below the Get-DiskInfo function's closing }, paste the Get-TopProcesses function:
function Get-TopProcesses {
    try {
        Get-Process |
            Sort-Object -Property WS -Descending |
            Select-Object -First 10 -Property Name, Id,
                @{Label = "Memory(MB)"; Expression = { [math]::Round($_.WS / 1MB, 2) }},
                @{Label = "CPU(s)"; Expression = { if ($_.CPU) { [math]::Round($_.CPU, 2) } else { 0 } }}
    }
    catch {
        Write-Warning "Failed to retrieve process information: $_"
        [PSCustomObject]@{
            Name        = "Error"
            Id          = 0
            "Memory(MB)" = 0
            "CPU(s)"    = 0
        }
    }
}

What is the object pipeline doing here?

This function demonstrates PowerShell's object pipeline in action. Get-Process outputs process objects, which are piped to Sort-Object (sorting by the WS property, which is working set memory), then piped to Select-Object to grab only the top 10.

Unlike text-based shells where you parse strings, PowerShell passes full .NET objects between commands. Each pipe symbol hands structured data to the next cmdlet.

  • Save Get-SystemHealthReport.ps1.

Seeing red squiggles in VS Code?

The PowerShell extension may show warnings if it is still analyzing the file. Save the file and wait a few seconds for the language server to catch up. If you see a syntax error, double-check that each function's opening { has a matching closing }.

help me fix syntax errors in my PowerShell script

Call the functions and test the script

The functions are defined but not yet called. You need to invoke each one and store the results in variables that the rest of the script will use later.

  • At the very bottom of Get-SystemHealthReport.ps1, below all three functions, paste these lines:
$osInfo = Get-OSInfo
$diskInfo = Get-DiskInfo
$topProcesses = Get-TopProcesses

What does this code do?

Each line calls a function and stores the returned object(s) in a variable. These variables hold structured data that you will convert to HTML in the next step.

  • Save Get-SystemHealthReport.ps1.
  • Load the script into your current terminal session by dot-sourcing it. Run this command in the VS Code integrated terminal:
. .\Get-SystemHealthReport.ps1

Why dot-source instead of just running it?

Running a script with .\ executes it in a child scope. Once it finishes, the variables disappear. Dot-sourcing (a dot, then a space, then the path) runs the script in your current session so $osInfo, $diskInfo, and $topProcesses stay available for you to inspect.

  • View your OS info by running:
$osInfo | Format-Table
  • View disk info by running:
$diskInfo | Format-Table
  • View the top processes by running:
$topProcesses | Format-Table

You should see formatted tables in your terminal. The OS table shows your computer name, Windows version, and memory usage. The disk table shows each drive's size and free space. The processes table shows the 10 programs using the most memory.

Not seeing any output?

Make sure you used dot-sourcing (a dot, a space, then the script path). If you ran .\Get-SystemHealthReport.ps1 without the leading dot and space, the variables exist only during execution and are gone by the time you try Format-Table.

If you see a permissions error, run Set-ExecutionPolicy RemoteSigned -Scope CurrentUser and try again.

help me troubleshoot why my PowerShell script produces no output

✔️ Awesome, I've got everything!

Great. Double check you have saved Get-SystemHealthReport.ps1 with all three functions and the variable assignments at the bottom.

ⓧ I'd like to double check the full code

Here is the complete Get-SystemHealthReport.ps1 at this point:

#Requires -Version 7.0

[CmdletBinding()]
param(
    [Parameter()]
    [string]$OutputPath = ".\HealthReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html"
)

$ErrorActionPreference = 'Stop'

function Get-OSInfo {
    try {
        $os = Get-CimInstance -ClassName Win32_OperatingSystem
        [PSCustomObject]@{
            ComputerName    = $env:COMPUTERNAME
            OSName          = $os.Caption
            Version         = $os.Version
            LastBoot        = $os.LastBootUpTime
            "TotalMemory(GB)" = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
            "FreeMemory(GB)"  = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
            "MemoryUsed(%)"   = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 1)
        }
    }
    catch {
        Write-Warning "Failed to retrieve OS information: $_"
        [PSCustomObject]@{
            ComputerName    = $env:COMPUTERNAME
            OSName          = "Error retrieving data"
            Version         = "N/A"
            LastBoot        = "N/A"
            "TotalMemory(GB)" = 0
            "FreeMemory(GB)"  = 0
            "MemoryUsed(%)"   = 0
        }
    }
}

function Get-DiskInfo {
    try {
        Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" |
            Select-Object -Property DeviceID,
                @{Label = "Size(GB)"; Expression = { [math]::Round($_.Size / 1GB, 2) }},
                @{Label = "FreeSpace(GB)"; Expression = { [math]::Round($_.FreeSpace / 1GB, 2) }},
                @{Label = "Used(%)"; Expression = { [math]::Round((($_.Size - $_.FreeSpace) / $_.Size) * 100, 1) }}
    }
    catch {
        Write-Warning "Failed to retrieve disk information: $_"
        [PSCustomObject]@{
            DeviceID       = "Error"
            "Size(GB)"     = 0
            "FreeSpace(GB)" = 0
            "Used(%)"      = 0
        }
    }
}

function Get-TopProcesses {
    try {
        Get-Process |
            Sort-Object -Property WS -Descending |
            Select-Object -First 10 -Property Name, Id,
                @{Label = "Memory(MB)"; Expression = { [math]::Round($_.WS / 1MB, 2) }},
                @{Label = "CPU(s)"; Expression = { if ($_.CPU) { [math]::Round($_.CPU, 2) } else { 0 } }}
    }
    catch {
        Write-Warning "Failed to retrieve process information: $_"
        [PSCustomObject]@{
            Name        = "Error"
            Id          = 0
            "Memory(MB)" = 0
            "CPU(s)"    = 0
        }
    }
}

$osInfo = Get-OSInfo
$diskInfo = Get-DiskInfo
$topProcesses = Get-TopProcesses

Your data-gathering layer is complete. Next up, you will take these raw objects and transform them into a styled HTML report you can open in your browser.

Generate a Styled HTML Report

Your data-gathering functions are collecting live system metrics. You have OS info, disk stats, and process data sitting in variables, ready to go.

Raw data in the terminal is useful for debugging, but a formatted HTML report is what you'd share with a team or archive for records. In this step, you'll transform your pipeline data into a styled, browser-ready report using ConvertTo-Html.

In this step, get ready to:
  • Define a CSS stylesheet for the report.
  • Convert each data section into an HTML table fragment.
  • Assemble and write the final report to a file.
Build the HTML report with ConvertTo-Html

PowerShell's ConvertTo-Html cmdlet turns objects into HTML. The -Fragment parameter generates just an HTML table (no surrounding page structure), which lets you build multi-section reports piece by piece.

  • In Get-SystemHealthReport.ps1, find the $topProcesses = Get-TopProcesses line at the bottom of your file.
  • Add the report date and CSS stylesheet below it by copying this code:
# Capture the current date and time for the report header
$reportDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss"

# Define the CSS stylesheet for the HTML report
$head = @"
<style>
    body { font-family: Segoe UI, Arial, sans-serif; margin: 20px; background: #f5f5f5; }
    h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
    h2 { color: #2980b9; margin-top: 30px; }
    table { border-collapse: collapse; width: 100%; margin-bottom: 20px; background: white; }
    th { background: #3498db; color: white; padding: 10px 15px; text-align: left; }
    td { padding: 8px 15px; border-bottom: 1px solid #ddd; }
    tr:nth-child(even) { background: #f2f2f2; }
    .report-header { color: #7f8c8d; font-size: 14px; }
</style>
"@

What does this code do?

  • $reportDate captures the current timestamp so the report shows when it was generated.
  • $head is a here-string (@"..."@) containing an embedded CSS stylesheet. Here-strings let you write multi-line text without escaping quotes.
  • The CSS styles tables with a blue header row, alternating row colors, and clean spacing. These styles apply to the HTML tables that ConvertTo-Html generates.

You won't see a change yet. The next code block uses this stylesheet to format the report.

  • Convert each data variable into an HTML table fragment by adding this code below the $head here-string:
# Convert each data set into an HTML table fragment with a section heading
$osFragment = $osInfo | ConvertTo-Html -Fragment -PreContent "<h2>Operating System</h2>"
$diskFragment = $diskInfo | ConvertTo-Html -Fragment -PreContent "<h2>Disk Space</h2>"
$processFragment = $topProcesses | ConvertTo-Html -Fragment -PreContent "<h2>Top 10 Processes by Memory</h2>"

What does this code do?

Each line pipes a data variable through ConvertTo-Html -Fragment. This generates only an HTML table from the object's properties. No surrounding <html> or <body> tags are included.

The -PreContent parameter injects an <h2> heading before each table, giving the report clear section labels.

You won't see a change yet. The next chunk assembles these fragments into a complete page.

Understand how calculated properties format the report columns

When ConvertTo-Html turns your objects into a table, each property becomes a column header. Your functions already use calculated properties to produce readable column names and values.

What are calculated properties?

A calculated property is a hashtable with Label and Expression keys. The label becomes the column name, and the expression computes the value. For example, in your Get-DiskInfo function:

@{Label = "Size(GB)"; Expression = { [math]::Round($_.Size / 1GB, 2) }}

This takes the raw disk size (in bytes) and divides by 1GB to produce a human-readable gigabyte value. Without this, the HTML table would show raw byte counts like 512110190592 instead of 476.94.

The same pattern applies to memory (dividing KB by 1MB to get GB) and the percentage-used calculations. These properties are already defined in your functions from the previous step.

Write the report to a file and run the script
  • Assemble the fragments into a complete HTML page and write it to a file by adding this code below the fragment variables:
# Assemble the report body with a title, timestamp, and all table fragments
$body = @"
<h1>System Health Report</h1>
<p class="report-header">Generated: $reportDate | Computer: $env:COMPUTERNAME</p>
$osFragment
$diskFragment
$processFragment
"@

# Wrap everything in a full HTML page and write to the output file
ConvertTo-Html -Head $head -Body $body -Title "System Health Report" |
    Out-File -FilePath $OutputPath -Encoding utf8

# Confirm the report path and open it in the default browser
Write-Output "Report generated: $OutputPath"
Invoke-Item $OutputPath

What does this code do?

  • $body is another here-string that combines an <h1> title, a timestamp paragraph, and all three table fragments into one block of HTML content.
  • ConvertTo-Html -Head $head -Body $body wraps everything in a proper HTML page. The -Head parameter injects your CSS into the <head> element. The -Body parameter fills the <body>.
  • Out-File saves the HTML to the path stored in $OutputPath (your param block's default generates a timestamped filename).
  • Invoke-Item opens the file in your default browser so you see the report immediately.
  • Save Get-SystemHealthReport.ps1.
  • Run the script in your VS Code terminal by running this command:
.\Get-SystemHealthReport.ps1
  • Your default browser opens with a styled HTML report showing your OS info, disk space, and top 10 processes in formatted tables with blue headers and alternating row colors.

Report not opening in your browser?

  • If you see a red error about execution policy, run Set-ExecutionPolicy RemoteSigned -Scope CurrentUser in your terminal first, then try again.
  • If the file is created but doesn't open, check that you have a default browser set. You can also navigate to C:\Scripts\HealthMonitor in File Explorer and double-click the .html file.
  • If you see an error about ConvertTo-Html, make sure the $head and $body here-strings start with @" on its own line and end with "@ at the very beginning of a new line (no leading spaces).

Help me troubleshoot my PowerShell HTML report. The script runs but the report doesn't open or looks broken.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before continuing.

ⓧ I'd like to double check the full code

#Requires -Version 7.0

[CmdletBinding()]
param(
    [Parameter()]
    [string]$OutputPath = ".\HealthReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html"
)

$ErrorActionPreference = 'Stop'

function Get-OSInfo {
    try {
        $os = Get-CimInstance -ClassName Win32_OperatingSystem
        [PSCustomObject]@{
            ComputerName    = $env:COMPUTERNAME
            OSName          = $os.Caption
            Version         = $os.Version
            LastBoot        = $os.LastBootUpTime
            "TotalMemory(GB)" = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
            "FreeMemory(GB)"  = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
            "MemoryUsed(%)"   = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 1)
        }
    }
    catch {
        Write-Warning "Failed to retrieve OS information: $_"
        [PSCustomObject]@{
            ComputerName    = $env:COMPUTERNAME
            OSName          = "Error retrieving data"
            Version         = "N/A"
            LastBoot        = "N/A"
            "TotalMemory(GB)" = 0
            "FreeMemory(GB)"  = 0
            "MemoryUsed(%)"   = 0
        }
    }
}

function Get-DiskInfo {
    try {
        Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" |
            Select-Object -Property DeviceID,
                @{Label = "Size(GB)"; Expression = { [math]::Round($_.Size / 1GB, 2) }},
                @{Label = "FreeSpace(GB)"; Expression = { [math]::Round($_.FreeSpace / 1GB, 2) }},
                @{Label = "Used(%)"; Expression = { [math]::Round((($_.Size - $_.FreeSpace) / $_.Size) * 100, 1) }}
    }
    catch {
        Write-Warning "Failed to retrieve disk information: $_"
        [PSCustomObject]@{
            DeviceID       = "Error"
            "Size(GB)"     = 0
            "FreeSpace(GB)" = 0
            "Used(%)"      = 0
        }
    }
}

function Get-TopProcesses {
    try {
        Get-Process |
            Sort-Object -Property WS -Descending |
            Select-Object -First 10 -Property Name, Id,
                @{Label = "Memory(MB)"; Expression = { [math]::Round($_.WS / 1MB, 2) }},
                @{Label = "CPU(s)"; Expression = { if ($_.CPU) { [math]::Round($_.CPU, 2) } else { 0 } }}
    }
    catch {
        Write-Warning "Failed to retrieve process information: $_"
        [PSCustomObject]@{
            Name        = "Error"
            Id          = 0
            "Memory(MB)" = 0
            "CPU(s)"    = 0
        }
    }
}

$osInfo = Get-OSInfo
$diskInfo = Get-DiskInfo
$topProcesses = Get-TopProcesses

$reportDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss"

$head = @"
<style>
    body { font-family: Segoe UI, Arial, sans-serif; margin: 20px; background: #f5f5f5; }
    h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
    h2 { color: #2980b9; margin-top: 30px; }
    table { border-collapse: collapse; width: 100%; margin-bottom: 20px; background: white; }
    th { background: #3498db; color: white; padding: 10px 15px; text-align: left; }
    td { padding: 8px 15px; border-bottom: 1px solid #ddd; }
    tr:nth-child(even) { background: #f2f2f2; }
    .report-header { color: #7f8c8d; font-size: 14px; }
</style>
"@

$osFragment = $osInfo | ConvertTo-Html -Fragment -PreContent "<h2>Operating System</h2>"
$diskFragment = $diskInfo | ConvertTo-Html -Fragment -PreContent "<h2>Disk Space</h2>"
$processFragment = $topProcesses | ConvertTo-Html -Fragment -PreContent "<h2>Top 10 Processes by Memory</h2>"

$body = @"
<h1>System Health Report</h1>
<p class="report-header">Generated: $reportDate | Computer: $env:COMPUTERNAME</p>
$osFragment
$diskFragment
$processFragment
"@

ConvertTo-Html -Head $head -Body $body -Title "System Health Report" |
    Out-File -FilePath $OutputPath -Encoding utf8

Write-Output "Report generated: $OutputPath"
Invoke-Item $OutputPath

Your script now collects live system data and presents it as a polished, browser-ready report. Next up, you'll add error handling and professional documentation so the script is production-ready.

Add Error Handling and Script Polish

Your script already collects system data and renders it as a styled HTML report. The functions work, but a production script needs two more things: documentation that other people (or future you) can read, and proof that failures are handled gracefully.

In this step you will add comment-based help so PowerShell's built-in Get-Help system can describe your script. Then you will deliberately break a CIM query to prove your error handling catches failures without crashing.

In this step, get ready to:
  • Review how the existing error handling pattern protects your script.
  • Add comment-based help so Get-Help describes your script.
  • Test error handling by intentionally breaking a CIM query.
Review the error handling pattern

Before adding new code, take a moment to understand how the error handling already built into your script works. Open Get-SystemHealthReport.ps1 and look at the top of the file.

  • Find this line near the top of your script:
$ErrorActionPreference = 'Stop'

What does this line do?

PowerShell has two categories of errors: terminating (halt the pipeline immediately) and non-terminating (write an error but keep going). Many cmdlets produce non-terminating errors by default.

Setting $ErrorActionPreference = 'Stop' converts every non-terminating error into a terminating error. This means your try/catch blocks can intercept all failures, not just the obvious ones.

  • Now look at any of your three functions (Get-OSInfo, Get-DiskInfo, or Get-TopProcesses). Each one follows the same pattern:
function Get-OSInfo {
    try {
        # Normal data-gathering logic
    }
    catch {
        Write-Warning "Failed to retrieve OS information: $_"
        [PSCustomObject]@{
            # Fallback values so the report still renders
        }
    }
}

Why use try/catch with fallback objects?

If a CIM query fails (permissions issue, WMI corruption, remote machine unreachable), the catch block logs a warning and returns a placeholder object. The rest of the script continues because it still receives an object with the expected properties.

The result is a partial report with a warning message instead of a total crash. This is the standard resilience pattern for sysadmin scripts.

Add comment-based help to your script

PowerShell supports a built-in documentation system called comment-based help. When you add structured comments with keywords like .SYNOPSIS and .DESCRIPTION, the Get-Help cmdlet can display them just like built-in cmdlet documentation.

  • In Get-SystemHealthReport.ps1, find the #Requires -Version 7.0 line at the very top of the file.
  • Directly below that line (and above the [CmdletBinding()] line), add the following comment-based help block:
<#
.SYNOPSIS
    Generates a system health report as a styled HTML file.

.DESCRIPTION
    Collects OS information, memory usage, disk space, and top processes
    from the local machine using CIM cmdlets and outputs a formatted HTML report.

.PARAMETER OutputPath
    The file path for the generated HTML report. Defaults to a timestamped file
    in the current directory.

.EXAMPLE
    .\Get-SystemHealthReport.ps1
    Generates a health report with default settings.

.EXAMPLE
    .\Get-SystemHealthReport.ps1 -OutputPath "C:\Reports\health.html"
    Generates a health report at the specified path.
#>

What does this help block do?

  • .SYNOPSIS provides a one-line description shown when someone browses available scripts.
  • .DESCRIPTION gives a detailed explanation of what the script does.
  • .PARAMETER documents each parameter. PowerShell auto-generates the syntax section from your param block.
  • .EXAMPLE shows usage examples. You can include multiple .EXAMPLE sections.
  • Save Get-SystemHealthReport.ps1.
  • Verify the help works by running this command in your integrated terminal:
Get-Help .\Get-SystemHealthReport.ps1

You should see output showing the script name, synopsis, syntax, description, and examples.

Get-Help shows nothing or an error?

  • Make sure you saved the file after adding the help block. Unsaved changes are invisible to Get-Help.
  • Confirm the help block sits between #Requires -Version 7.0 and [CmdletBinding()]. It must be above the param block for script-level help.
  • Use the relative path .\Get-SystemHealthReport.ps1 (with the dot-backslash) so PowerShell finds the file in your current directory.

Help me troubleshoot why Get-Help isn't showing my script documentation.

Test error handling with an invalid class

Your error handling is in place, but you have not seen it fire yet. The best way to prove it works is to deliberately trigger a failure and watch the script recover.

  • In Get-SystemHealthReport.ps1, scroll to the Get-DiskInfo function and find this line inside the try block:
Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" |
  • Temporarily change Win32_LogicalDisk to Win32_FakeDisk so the line looks like this:
Get-CimInstance -ClassName Win32_FakeDisk -Filter "DriveType=3" |
  • Save the file and run the script:
.\Get-SystemHealthReport.ps1

You should see a yellow warning message in the terminal saying the disk query failed. The report still opens in your browser with the OS and process tables intact, and the disk section shows fallback values.

Why does this matter?

In production, CIM queries can fail for many reasons: permissions, WMI corruption, or a remote machine being offline. Your script degrades gracefully by showing partial data rather than crashing and leaving the operator with nothing.

  • Now revert the class name back to Win32_LogicalDisk so the line reads:
Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" |
  • Save Get-SystemHealthReport.ps1.
  • Run the script one final time to confirm a complete report generates successfully:
.\Get-SystemHealthReport.ps1

The HTML report opens in your browser with all three sections populated with real data.

Still seeing the warning after reverting?

  • Double-check you changed Win32_FakeDisk back to Win32_LogicalDisk exactly (case does not matter, but the class name must be correct).
  • Confirm you saved the file before running it again.

Help me figure out why my disk query is still failing after reverting the class name.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file and that the final run produces a full report with no warnings.

ⓧ I'd like to double check the full code

Here is the complete Get-SystemHealthReport.ps1 at the end of this step:

#Requires -Version 7.0

<#
.SYNOPSIS
    Generates a system health report as a styled HTML file.

.DESCRIPTION
    Collects OS information, memory usage, disk space, and top processes
    from the local machine using CIM cmdlets and outputs a formatted HTML report.

.PARAMETER OutputPath
    The file path for the generated HTML report. Defaults to a timestamped file
    in the current directory.

.EXAMPLE
    .\Get-SystemHealthReport.ps1
    Generates a health report with default settings.

.EXAMPLE
    .\Get-SystemHealthReport.ps1 -OutputPath "C:\Reports\health.html"
    Generates a health report at the specified path.
#>

[CmdletBinding()]
param(
    [Parameter()]
    [string]$OutputPath = ".\HealthReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html"
)

$ErrorActionPreference = 'Stop'

function Get-OSInfo {
    try {
        $os = Get-CimInstance -ClassName Win32_OperatingSystem
        [PSCustomObject]@{
            ComputerName    = $env:COMPUTERNAME
            OSName          = $os.Caption
            Version         = $os.Version
            LastBoot        = $os.LastBootUpTime
            "TotalMemory(GB)" = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
            "FreeMemory(GB)"  = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
            "MemoryUsed(%)"   = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 1)
        }
    }
    catch {
        Write-Warning "Failed to retrieve OS information: $_"
        [PSCustomObject]@{
            ComputerName    = $env:COMPUTERNAME
            OSName          = "Error retrieving data"
            Version         = "N/A"
            LastBoot        = "N/A"
            "TotalMemory(GB)" = 0
            "FreeMemory(GB)"  = 0
            "MemoryUsed(%)"   = 0
        }
    }
}

function Get-DiskInfo {
    try {
        Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" |
            Select-Object -Property DeviceID,
                @{Label = "Size(GB)"; Expression = { [math]::Round($_.Size / 1GB, 2) }},
                @{Label = "FreeSpace(GB)"; Expression = { [math]::Round($_.FreeSpace / 1GB, 2) }},
                @{Label = "Used(%)"; Expression = { [math]::Round((($_.Size - $_.FreeSpace) / $_.Size) * 100, 1) }}
    }
    catch {
        Write-Warning "Failed to retrieve disk information: $_"
        [PSCustomObject]@{
            DeviceID       = "Error"
            "Size(GB)"     = 0
            "FreeSpace(GB)" = 0
            "Used(%)"      = 0
        }
    }
}

function Get-TopProcesses {
    try {
        Get-Process |
            Sort-Object -Property WS -Descending |
            Select-Object -First 10 -Property Name, Id,
                @{Label = "Memory(MB)"; Expression = { [math]::Round($_.WS / 1MB, 2) }},
                @{Label = "CPU(s)"; Expression = { if ($_.CPU) { [math]::Round($_.CPU, 2) } else { 0 } }}
    }
    catch {
        Write-Warning "Failed to retrieve process information: $_"
        [PSCustomObject]@{
            Name        = "Error"
            Id          = 0
            "Memory(MB)" = 0
            "CPU(s)"    = 0
        }
    }
}

$osInfo = Get-OSInfo
$diskInfo = Get-DiskInfo
$topProcesses = Get-TopProcesses

$reportDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss"

$head = @"
<style>
    body { font-family: Segoe UI, Arial, sans-serif; margin: 20px; background: #f5f5f5; }
    h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
    h2 { color: #2980b9; margin-top: 30px; }
    table { border-collapse: collapse; width: 100%; margin-bottom: 20px; background: white; }
    th { background: #3498db; color: white; padding: 10px 15px; text-align: left; }
    td { padding: 8px 15px; border-bottom: 1px solid #ddd; }
    tr:nth-child(even) { background: #f2f2f2; }
    .report-header { color: #7f8c8d; font-size: 14px; }
</style>
"@

$osFragment = $osInfo | ConvertTo-Html -Fragment -PreContent "<h2>Operating System</h2>"
$diskFragment = $diskInfo | ConvertTo-Html -Fragment -PreContent "<h2>Disk Space</h2>"
$processFragment = $topProcesses | ConvertTo-Html -Fragment -PreContent "<h2>Top 10 Processes by Memory</h2>"

$body = @"
<h1>System Health Report</h1>
<p class="report-header">Generated: $reportDate | Computer: $env:COMPUTERNAME</p>
$osFragment
$diskFragment
$processFragment
"@

ConvertTo-Html -Head $head -Body $body -Title "System Health Report" |
    Out-File -FilePath $OutputPath -Encoding utf8

Write-Output "Report generated: $OutputPath"
Invoke-Item $OutputPath

Your script is now fully polished with documentation and battle-tested error handling. Anyone on your team can run Get-Help to understand what it does, and any single-section failure produces a warning instead of a crash.

Secret mission

Add Threshold-Based Alerting

Your health report shows the data, but it treats every metric the same. What if memory is at 95% usage? What if a disk is almost full? In this extension, you'll add a configurable alerting system that highlights dangerous metrics in red so problems jump off the page.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your files and tools in place, or clean them up.

Resources you used:

  • PowerShell 7.6 LTS (installed via winget).
  • VS Code PowerShell extension (ms-vscode.powershell).
  • Project folder C:\Scripts\HealthMonitor with Get-SystemHealthReport.ps1 and generated HTML reports.

Keep everything running

No action needed. Choose this if you plan to keep building on your health monitor script or want PowerShell 7 available for future projects.

  • Your script and generated HTML reports stay in C:\Scripts\HealthMonitor.
  • PowerShell 7.6 remains installed side-by-side with Windows PowerShell 5.1.
  • The VS Code PowerShell extension stays active for any future PowerShell work.

Pause - I'll come back to this later

Since everything is local files and installed tools, there is nothing to pause. Your script will be waiting for you when you return.

  • Optionally disable the PowerShell extension in VS Code by opening the Extensions panel (Ctrl+Shift+X), finding PowerShell, and clicking Disable.
  • You can re-enable it any time you want to work with PowerShell again.

Delete - I don't want to use this again

Remove all project files and optionally uninstall the tools you set up during this project.

  • Delete the project folder by running this command in PowerShell:
Remove-Item -Path "C:\Scripts\HealthMonitor" -Recurse -Force
  • If C:\Scripts is now empty, remove it too:
Remove-Item -Path "C:\Scripts" -Force
  • Uninstall the VS Code PowerShell extension by opening the Extensions panel (Ctrl+Shift+X), finding PowerShell, and clicking Uninstall.
  • Uninstall PowerShell 7.6 by running this command in Command Prompt or Windows PowerShell 5.1:
winget uninstall --id Microsoft.PowerShell

Why uninstall from a different shell?

You cannot uninstall PowerShell 7 while running inside it. Use Command Prompt or Windows PowerShell 5.1 to run the winget uninstall command.

  • Confirm removal by typing pwsh in your terminal. You should see a "not recognized" or "not found" message.

Nice Work!

Nice Work!

Nice work! You just built a production-style system health monitoring script that queries live data from your Windows machine and generates a styled HTML report you can open in any browser.

You've learned how to:

  • Built a system health monitoring script using CIM/WMI classes, the object pipeline, functions, param blocks, and error handling.
  • Generated a styled HTML report with calculated properties and ConvertTo-Html to format raw system data into human-readable output.
  • Applied production PowerShell patterns including #Requires, [CmdletBinding()], comment-based help, and try/catch/finally error handling to make your script resilient and self-documenting.
  • Secret Mission: Added threshold-based alerting with conditional formatting and parameter validation to highlight metrics exceeding configurable warning levels.

Ready to quiz yourself?