P05: System Health Dashboard Generator
P05: System Health Dashboard Generator
Project Overview
What you’ll build: A PowerShell script that collects system metrics (CPU, memory, disk, running services, recent errors) and generates an HTML report you can open in a browser.
| Attribute | Value |
|---|---|
| Difficulty | Level 1: Beginner |
| Time Estimate | Weekend (8-12 hours) |
| Programming Language | PowerShell |
| Knowledge Area | System Administration |
| Prerequisites | Basic command-line familiarity |
1. Learning Objectives
After completing this project, you will be able to:
- Query system information with WMI/CIM - Access CPU, memory, disk data programmatically, treating Windows as a queryable database
- Master the PowerShell pipeline paradigm - Understand that PowerShell pipes objects, not text, fundamentally changing how you think about data transformation
- Filter and transform data - Use
Where-ObjectandSelect-Objecteffectively for projection and filtering - Create calculated properties - Transform raw values (bytes) into human-readable formats (GB) on the fly
- Generate formatted HTML output - Create professional reports from PowerShell objects
- Handle errors gracefully - Use try/catch for robust, production-ready scripts
- Create reusable parameters - Build scripts with command-line arguments using the param() block
- Implement status thresholds - Design logic that classifies metrics as normal, warning, or critical
2. Deep Theoretical Foundation
WMI and CIM: Windows as a Queryable Database
What is WMI?
Windows Management Instrumentation (WMI) is Windows’ implementation of WBEM (Web-Based Enterprise Management). Think of it as a massive database that contains everything about your system - hardware, software, configuration, running processes, and more.
The key insight: WMI treats your entire Windows system as queryable data. Instead of parsing text output from commands, you query structured objects with properties and methods.
The WMI Repository Architecture:
+------------------------------------------------------------------+
| WMI Architecture |
+------------------------------------------------------------------+
| |
| +-----------------+ +------------------+ |
| | Your Script | | Other Tools | |
| | (PowerShell) | | (WMI Explorer) | |
| +--------+--------+ +--------+---------+ |
| | | |
| v v |
| +------------------------------------------+ |
| | WMI Service (winmgmt) | |
| | - Receives queries | |
| | - Routes to appropriate provider | |
| | - Returns structured objects | |
| +------------------+----------------------+ |
| | |
| +------------------v----------------------+ |
| | WMI Repository | |
| | | |
| | Namespace: root\cimv2 (most common) | |
| | +----------------------------------+ | |
| | | Win32_OperatingSystem | | |
| | | - Caption: "Windows 11 Pro" | | |
| | | - TotalVisibleMemorySize | | |
| | | - FreePhysicalMemory | | |
| | | - LastBootUpTime | | |
| | +----------------------------------+ | |
| | | Win32_Processor | | |
| | | - Name: "Intel Core i7-12700" | | |
| | | - NumberOfCores: 12 | | |
| | | - LoadPercentage: 25 | | |
| | +----------------------------------+ | |
| | | Win32_LogicalDisk | | |
| | | - DeviceID: "C:" | | |
| | | - Size: 512110190592 | | |
| | | - FreeSpace: 256055095296 | | |
| | +----------------------------------+ | |
| | | Win32_Service | | |
| | | - Name: "wuauserv" | | |
| | | - State: "Running" | | |
| | +----------------------------------+ | |
| +------------------------------------------+ |
| | |
| +------------------v----------------------+ |
| | WMI Providers | |
| | - Query actual hardware/software | |
| | - Return real-time data | |
| +------------------------------------------+ |
+------------------------------------------------------------------+
WMI vs CIM - The Evolution:
| Aspect | WMI (Legacy) | CIM (Modern) |
|---|---|---|
| Cmdlets | Get-WmiObject |
Get-CimInstance |
| Protocol | DCOM (Distributed COM) | WS-Man (WinRM) |
| Performance | Slower, heavier | Faster, more efficient |
| Remote support | Requires DCOM configuration | Uses standard WinRM |
| Firewall friendly | No (dynamic ports) | Yes (single port 5985/5986) |
| PowerShell version | All versions | 3.0+ |
| Future | Deprecated | Actively developed |
Best Practice: Always use CIM (Get-CimInstance) for new scripts. WMI is legacy.
Key CIM Classes for System Health:
| Class | Information Provided | Example Query |
|---|---|---|
Win32_OperatingSystem |
OS version, memory, uptime, boot time | Get-CimInstance Win32_OperatingSystem |
Win32_Processor |
CPU name, cores, current load | Get-CimInstance Win32_Processor |
Win32_LogicalDisk |
Drive letters, sizes, free space | Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
Win32_Service |
Windows services and their states | Get-CimInstance Win32_Service |
Win32_Process |
Running processes | Get-CimInstance Win32_Process |
Win32_PhysicalMemory |
RAM modules, speeds | Get-CimInstance Win32_PhysicalMemory |
Win32_ComputerSystem |
Computer name, domain, manufacturer | Get-CimInstance Win32_ComputerSystem |
Win32_NetworkAdapterConfiguration |
IP addresses, DNS settings | Get-CimInstance Win32_NetworkAdapterConfiguration |
Why CIM is Better for Remote Administration:
Traditional WMI (DCOM):
+----------+ +----------+
| Client |----[Dynamic Ports]------->| Server |
| |<---[DCOM Callbacks]-------| |
+----------+ (Firewall nightmare!) +----------+
Modern CIM (WS-Man):
+----------+ +----------+
| Client |----[Port 5985/5986]------>| Server |
| |<---[Same Port]------------| |
+----------+ (Single port, firewall +----------+
friendly!)
The Pipeline Paradigm: Objects, Not Text
This is the single most important concept that separates PowerShell from traditional shells like Bash. Understanding this deeply will transform how you write scripts.
The Unix/Bash Paradigm (Text Streams):
In Bash, everything is text. Commands emit lines of text, and you parse that text:
# Get process ID of firefox
ps aux | grep firefox | awk '{print $2}'
# What's happening:
# 1. ps aux outputs TEXT (columns separated by spaces)
# 2. grep filters lines containing "firefox" (pattern matching on TEXT)
# 3. awk extracts the 2nd column (splitting TEXT on whitespace)
# PROBLEMS:
# - Column positions can vary
# - You're constantly parsing, splitting, and reassembling strings
# - No type safety - everything is a string
# - Fragile: if output format changes, script breaks
The PowerShell Paradigm (Object Streams):
In PowerShell, everything is an object with properties and methods:
# Get process ID of firefox
Get-Process firefox | Select-Object Id
# What's happening:
# 1. Get-Process returns OBJECTS with properties (Name, Id, CPU, etc.)
# 2. Select-Object picks specific properties from those objects
# 3. No text parsing needed!
# ADVANTAGES:
# - Properties are named, not positional
# - Type safety - integers are integers, dates are dates
# - Discoverable - use Get-Member to explore any object
# - Robust - property names don't change like column positions
Visualizing the Pipeline Data Flow:
Text-Based Pipeline (Bash):
+-------------+ "firefox 1234 0.5 ..." +--------+ "firefox" +-------+
| ps aux |----------------------------->| grep |---------------->| awk |
+-------------+ (Line of text) +--------+ (Text match) +-------+
|
"1234" (string)
Object-Based Pipeline (PowerShell):
+-------------+ ProcessObject +--------------+ ProcessObject
| Get-Process |--[{Name:"firefox",Id:1234,-->| Where-Object |--[{Name:"firefox",
+-------------+ CPU:0.5, ...}] +--------------+ Id:1234,...}]
|
v
+-------------+
| Select-Object
+-------------+
|
1234 (Int32)
The Pipeline in Detail:
+------------------------------------------------------------------------+
| PowerShell Pipeline Architecture |
+------------------------------------------------------------------------+
| |
| Get-CimInstance Win32_LogicalDisk |
| | |
| | Returns array of objects: |
| | |
| v |
| +------------------------------------------------------------------+ |
| | Object 1: | |
| | DeviceID : C: | |
| | Size : 512110190592 | |
| | FreeSpace : 256055095296 | |
| | DriveType : 3 | |
| | VolumeName : System | |
| +------------------------------------------------------------------+ |
| +------------------------------------------------------------------+ |
| | Object 2: | |
| | DeviceID : D: | |
| | Size : 1099511627776 | |
| | FreeSpace : 879609302221 | |
| | DriveType : 3 | |
| | VolumeName : Data | |
| +------------------------------------------------------------------+ |
| | |
| v |
| Where-Object { $_.FreeSpace -lt 300GB } |
| | |
| | Filters objects where FreeSpace < 300GB: |
| | (D: has 820GB free, so it's removed) |
| | |
| v |
| +------------------------------------------------------------------+ |
| | Object 1: | |
| | DeviceID : C: | |
| | Size : 512110190592 | |
| | FreeSpace : 256055095296 (238GB, less than 300GB) | |
| +------------------------------------------------------------------+ |
| | |
| v |
| Select-Object DeviceID, @{N='FreeGB'; E={[math]::Round($_.FreeSpace/1GB)}}
| | |
| | Projects specific properties (with transformation): |
| | |
| v |
| +------------------------------------------------------------------+ |
| | Result Object: | |
| | DeviceID : C: | |
| | FreeGB : 238 | |
| +------------------------------------------------------------------+ |
| |
+------------------------------------------------------------------------+
Key Pipeline Cmdlets:
| Cmdlet | Purpose | Example |
|---|---|---|
Where-Object |
Filter objects by condition | Get-Process \| Where-Object CPU -gt 10 |
Select-Object |
Choose/rename/calculate properties | Get-Process \| Select-Object Name, Id |
Sort-Object |
Order by property | Get-Process \| Sort-Object CPU -Descending |
Group-Object |
Group by property value | Get-Service \| Group-Object Status |
Measure-Object |
Calculate statistics | Get-Process \| Measure-Object CPU -Sum -Average |
ForEach-Object |
Transform each object | 1..10 \| ForEach-Object { $_ * 2 } |
Object Filtering and Projection
Where-Object: The Filter
Where-Object (alias: ? or where) filters objects based on a condition. Only objects that pass the test continue down the pipeline.
# Full syntax
Get-Service | Where-Object { $_.Status -eq 'Stopped' }
# Simplified syntax (PowerShell 3+)
Get-Service | Where-Object Status -eq 'Stopped'
# Using alias
Get-Service | ? Status -eq 'Stopped'
The Automatic Variable $_:
Inside a pipeline script block { }, the special variable $_ represents “the current object”:
Get-Process | Where-Object { $_.CPU -gt 100 -and $_.Name -like "*chrome*" }
# ^^^ ^^^
# Current process Current process
Select-Object: The Projector
Select-Object (alias: select) chooses which properties to keep, can rename them, or calculate new ones.
# Simple selection
Get-Process | Select-Object Name, Id, CPU
# First/Last N items
Get-Process | Sort-Object CPU -Descending | Select-Object -First 5
# Unique values
Get-Process | Select-Object Name -Unique
Calculated Properties - The Power Feature:
Calculated properties let you transform data on the fly:
# Syntax
@{
Name = 'NewPropertyName' # or use 'N', 'Label', or 'L'
Expression = { $_.xxx } # or use 'E'
}
# Example: Convert bytes to GB
Get-CimInstance Win32_LogicalDisk | Select-Object DeviceID,
@{Name='SizeGB'; Expression={[math]::Round($_.Size/1GB, 2)}},
@{Name='FreeGB'; Expression={[math]::Round($_.FreeSpace/1GB, 2)}},
@{Name='UsedPercent'; Expression={
[math]::Round(($_.Size - $_.FreeSpace) / $_.Size * 100, 1)
}}
# Output:
# DeviceID SizeGB FreeGB UsedPercent
# -------- ------ ------ -----------
# C: 476.94 238.47 50.0
# D: 1024.00 819.32 20.0
HTML Generation and Formatting
PowerShell can convert any object directly to HTML, making report generation trivial.
Basic HTML Conversion:
# Simple conversion - produces a complete HTML page
Get-Process | ConvertTo-Html | Out-File processes.html
# With title and basic styling
Get-Process | ConvertTo-Html -Title "Process Report" | Out-File report.html
The -Fragment Parameter:
When building multi-section reports, use -Fragment to get just the table:
# Full HTML document
Get-Process | ConvertTo-Html # Includes <html>, <head>, <body>
# Just the table
Get-Process | ConvertTo-Html -Fragment # Only <table>...</table>
Building Professional Reports:
+------------------------------------------------------------------------+
| HTML Report Generation Flow |
+------------------------------------------------------------------------+
| |
| $cpuData = Get-SystemMetrics |
| | |
| v |
| $cpuData | ConvertTo-Html -Fragment |
| | |
| v |
| "<table><tr><th>CPU</th>...</table>" |
| |
| $diskData = Get-DiskMetrics |
| | |
| v |
| $diskData | ConvertTo-Html -Fragment |
| | |
| v |
| "<table><tr><th>Drive</th>...</table>" |
| |
| + |
| | |
| $css = "<style>table{...}th{...}</style>" |
| | |
| v |
| +------------------------------------------------------------------+ |
| | $html = @" | |
| | <!DOCTYPE html> | |
| | <html><head>$css</head> | |
| | <body> | |
| | <h1>System Health Report</h1> | |
| | <h2>CPU & Memory</h2> | |
| | $cpuTable | |
| | <h2>Disk Usage</h2> | |
| | $diskTable | |
| | </body></html> | |
| | "@ | |
| +------------------------------------------------------------------+ |
| | |
| v |
| $html | Out-File report.html |
| |
+------------------------------------------------------------------------+
Adding Conditional Formatting with String Replacement:
# Convert data to HTML
$html = $diskData | ConvertTo-Html -Fragment
# Replace status values with colored spans
$html = $html -replace '<td>CRITICAL</td>', '<td class="critical">CRITICAL</td>'
$html = $html -replace '<td>WARNING</td>', '<td class="warning">WARNING</td>'
$html = $html -replace '<td>NORMAL</td>', '<td class="normal">NORMAL</td>'
Error Handling in Scripts
Robust scripts anticipate and handle errors gracefully.
Try/Catch/Finally:
try {
# Code that might fail
$data = Get-CimInstance Win32_Service -ErrorAction Stop
}
catch {
# Handle the error
Write-Warning "Failed to get services: $_"
$data = @() # Provide fallback
}
finally {
# Always runs (cleanup)
Write-Verbose "Query completed"
}
ErrorAction Parameter:
Controls how cmdlets respond to non-terminating errors:
| Value | Behavior |
|---|---|
Continue |
Display error, continue (default) |
SilentlyContinue |
Suppress error, continue |
Stop |
Convert to terminating error (catchable) |
Ignore |
Suppress completely (even $Error) |
Best Practice Pattern:
function Get-SafeData {
param([string]$Query)
try {
Get-CimInstance $Query -ErrorAction Stop
}
catch [Microsoft.Management.Infrastructure.CimException] {
# Specific WMI/CIM error
Write-Warning "CIM query failed: $($_.Exception.Message)"
return $null
}
catch {
# Any other error
Write-Error "Unexpected error: $_"
throw
}
}
3. Complete Project Specification
Functional Requirements
| ID | Requirement | Priority | Notes |
|---|---|---|---|
| F1 | Collect CPU usage percentage | Must Have | Use Win32_Processor.LoadPercentage |
| F2 | Collect memory usage (total, used, free, percent) | Must Have | Calculate from Win32_OperatingSystem |
| F3 | Collect disk space for all local drives | Must Have | Filter DriveType=3 (local fixed) |
| F4 | Show system uptime | Must Have | Calculate from LastBootUpTime |
| F5 | Generate HTML report with styling | Must Have | CSS for professional appearance |
| F6 | Color-code status (normal/warning/critical) | Must Have | Visual indicator of health |
| F7 | Show critical Windows services status | Should Have | Configurable service list |
| F8 | Show recent error events (last 24h) | Should Have | From Application/System logs |
| F9 | Support -OutputPath parameter | Should Have | Custom save location |
| F10 | Support -OpenInBrowser switch | Should Have | Auto-open after generation |
| F11 | Support -EmailTo parameter | Nice to Have | Send report via email |
| F12 | Export to PDF | Nice to Have | Requires external tooling |
Non-Functional Requirements
| ID | Requirement | Rationale |
|---|---|---|
| NF1 | Script completes in < 10 seconds | User experience - no waiting |
| NF2 | Works without administrator privileges | Most data available to standard users |
| NF3 | Compatible with PowerShell 5.1+ and 7+ | Support both Windows PowerShell and PowerShell Core |
| NF4 | Gracefully handles missing data | Don’t crash if a query fails |
| NF5 | UTF-8 output encoding | Proper character handling |
| NF6 | No external dependencies | Single-file script |
Thresholds Specification
$Thresholds = @{
CPU = @{
Warning = 80 # 80% or higher = warning
Critical = 95 # 95% or higher = critical
}
Memory = @{
Warning = 75 # 75% used = warning
Critical = 90 # 90% used = critical
}
Disk = @{
Warning = 80 # 80% used = warning
Critical = 95 # 95% used = critical
}
Uptime = @{
Warning = 90 # 90 days without reboot = warning
Critical = 180 # 180 days = critical (needs patching!)
}
}
4. Real World Outcome
Example Command Execution
Basic Usage:
PS C:\Scripts> .\Get-SystemHealth.ps1
Collecting system metrics...
[OK] CPU and Memory
[OK] Disk Information
[OK] Service Status
[OK] Recent Errors (3 found)
Generating HTML report...
Report saved to: C:\Users\Admin\AppData\Local\Temp\SystemHealth_20251226_142345.html
Opening in browser...
With Parameters:
# Save to specific location
PS> .\Get-SystemHealth.ps1 -OutputPath "C:\Reports\health.html"
# Don't auto-open browser
PS> .\Get-SystemHealth.ps1 -OutputPath "C:\Reports\health.html" -OpenInBrowser:$false
# Email the report (when implemented)
PS> .\Get-SystemHealth.ps1 -EmailTo "admin@company.com"
Example HTML Report Output (ASCII Mockup)
+=========================================================================+
| SYSTEM HEALTH REPORT |
| Computer: WORKSTATION-01 | Generated: 2025-12-26 14:23:45 |
+=========================================================================+
+------------+ +------------+ +------------+
| 47% | | 65.2% | | 45 days |
| CPU Usage | |Memory Used | | Uptime |
| [NORMAL] | | [NORMAL] | | [NORMAL] |
+------------+ +------------+ +------------+
+-------------------------------------------------------------------------+
| SYSTEM INFORMATION |
+-------------------------------------------------------------------------+
| OS Version: Microsoft Windows 11 Pro |
| CPU: AMD Ryzen 9 5900X 12-Core Processor (12 cores) |
| Total Memory: 32.00 GB |
| Last Boot: 2025-11-11 09:15:22 |
+-------------------------------------------------------------------------+
+-------------------------------------------------------------------------+
| DISK USAGE |
+-------------------------------------------------------------------------+
| Drive | Label | Size (GB) | Used (GB) | Free (GB) | Used % | Status |
|-------|----------|-----------|-----------|-----------|--------|---------|
| C: | System | 476.94 | 238.47 | 238.47 | 50.0% | NORMAL |
| D: | Data | 1024.00 | 921.60 | 102.40 | 90.0% |CRITICAL |
| E: | Backup | 512.00 | 409.60 | 102.40 | 80.0% | WARNING |
+-------------------------------------------------------------------------+
(Color coded: NORMAL=Green, WARNING=Yellow, CRITICAL=Red)
+-------------------------------------------------------------------------+
| CRITICAL SERVICES |
+-------------------------------------------------------------------------+
| Service Name | Status | Health |
|---------------------------|-------------|-------------|
| Windows Update | Running | NORMAL |
| Windows Defender | Running | NORMAL |
| Windows Event Log | Running | NORMAL |
| Print Spooler | Stopped | WARNING |
| SQL Server (MSSQLSERVER) | Not Installed| INFO |
+-------------------------------------------------------------------------+
+-------------------------------------------------------------------------+
| RECENT ERRORS (Last 24 Hours) |
+-------------------------------------------------------------------------+
| Time | Level | Source | Message |
|---------------------|----------|-----------------|----------------------|
| 2025-12-26 13:45:22 | Error | Application | App crashed unexpe...|
| 2025-12-26 12:10:19 | Error | WindowsUpdate | Update KB5034441 fa...|
| 2025-12-26 09:30:05 | Critical | Disk | Drive D: running low.|
+-------------------------------------------------------------------------+
Report generated by Get-SystemHealth.ps1
PowerShell 7.4.1
+=========================================================================+
Color-Coded Status Indicators
The HTML report uses CSS classes for visual distinction:
| Status | CSS Class | Background | Text Color | When Applied |
|---|---|---|---|---|
| NORMAL | .normal |
Light Green (#d4edda) | Dark Green (#155724) | Metric within healthy range |
| WARNING | .warning |
Light Yellow (#fff3cd) | Dark Yellow (#856404) | Approaching threshold |
| CRITICAL | .critical |
Light Red (#f8d7da) | Dark Red (#721c24) | Exceeded threshold |
| INFO | .info |
Light Blue (#cce5ff) | Dark Blue (#004085) | Informational (not applicable) |
Scheduling Options
Windows Task Scheduler (Recommended):
# Create scheduled task to run daily at 8 AM
$action = New-ScheduledTaskAction -Execute 'PowerShell.exe' `
-Argument '-NoProfile -File "C:\Scripts\Get-SystemHealth.ps1" -OutputPath "C:\Reports\daily_$(Get-Date -Format yyyyMMdd).html"'
$trigger = New-ScheduledTaskTrigger -Daily -At '8:00 AM'
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount
Register-ScheduledTask -TaskName 'DailyHealthReport' `
-Action $action -Trigger $trigger -Principal $principal
Continuous Monitoring (Run Every Hour):
# Using Task Scheduler repeat interval
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Hours 1) `
-RepetitionDuration (New-TimeSpan -Days 365)
5. Solution Architecture
Component Diagram
+=========================================================================+
| SYSTEM HEALTH DASHBOARD GENERATOR |
+=========================================================================+
| |
| +---------------------------+ +---------------------------+ |
| | PARAMETER HANDLER | | THRESHOLD CONFIG | |
| |---------------------------| |---------------------------| |
| | - Parse command line | | - CPU thresholds | |
| | - Set defaults | | - Memory thresholds | |
| | - Validate paths | | - Disk thresholds | |
| +------------+--------------+ | - Uptime thresholds | |
| | +------------+--------------+ |
| v | |
| +---------------------------------------------v-----------------------+ |
| | DATA COLLECTORS | |
| |---------------------------------------------------------------------| |
| | | |
| | +----------------+ +----------------+ +----------------+ | |
| | | Get-SystemInfo | | Get-DiskInfo | | Get-ServiceInfo| | |
| | |----------------| |----------------| |----------------| | |
| | | - OS version | | - All local | | - Critical | | |
| | | - CPU load | | drives | | services | | |
| | | - Memory | | - Size/free | | - Status check | | |
| | | - Uptime | | - Percentages | +----------------+ | |
| | +----------------+ +----------------+ | |
| | | |
| | +----------------+ | |
| | | Get-ErrorEvents| | |
| | |----------------| | |
| | | - Last 24h | | |
| | | - App/System | | |
| | | - Error/Crit | | |
| | +----------------+ | |
| +---------------------------------------------------------------------+ |
| | |
| v |
| +---------------------------------------------------------------------+ |
| | STATUS CALCULATOR | |
| |---------------------------------------------------------------------| |
| | - Apply thresholds to each metric | |
| | - Determine NORMAL / WARNING / CRITICAL | |
| | - Attach status property to each data object | |
| +----------------------------------+----------------------------------+ |
| | |
| v |
| +---------------------------------------------------------------------+ |
| | HTML GENERATOR | |
| |---------------------------------------------------------------------| |
| | | |
| | +----------------+ +----------------+ +----------------+ | |
| | | CSS Styles | | HTML Template | | Section Builder| | |
| | |----------------| |----------------| |----------------| | |
| | | - Table styles | | - Document | | - Summary cards| | |
| | | - Status colors| | structure | | - Data tables | | |
| | | - Cards layout | | - Meta tags | | - ConvertTo- | | |
| | +----------------+ +----------------+ | Html -Frag. | | |
| | +----------------+ | |
| +----------------------------------+----------------------------------+ |
| | |
| v |
| +---------------------------------------------------------------------+ |
| | OUTPUT HANDLER | |
| |---------------------------------------------------------------------| |
| | - Save HTML file to OutputPath | |
| | - Open in default browser (if -OpenInBrowser) | |
| | - Send email (if -EmailTo specified) | |
| +---------------------------------------------------------------------+ |
| |
+=========================================================================+
Data Collection Strategy
+------------------------------------------------------------------------+
| DATA COLLECTION FLOW |
+------------------------------------------------------------------------+
| |
| [Script Start] |
| | |
| v |
| Parse-Parameters |
| (OutputPath, OpenInBrowser, EmailTo, Verbose) |
| | |
| v |
| Initialize-Thresholds |
| (Set default warning/critical values) |
| | |
| v |
| +------------------------------------------------------------------+ |
| | PARALLEL DATA COLLECTION | |
| | (In practice, sequential but logically parallel) | |
| | | |
| | +--------------------+ Win32_OperatingSystem | |
| | | Get-SystemMetrics |--> Win32_Processor | |
| | +--------------------+ Win32_ComputerSystem | |
| | | | |
| | v | |
| | [PSCustomObject] with CPU, Memory, Uptime | |
| | | |
| | +--------------------+ Win32_LogicalDisk (DriveType=3) | |
| | | Get-DiskMetrics |--> Calculate percentages | |
| | +--------------------+ Apply thresholds | |
| | | | |
| | v | |
| | Array of [PSCustomObject] per drive | |
| | | |
| | +--------------------+ Get-Service (specific list) | |
| | | Get-ServiceStatus |--> Check Running/Stopped | |
| | +--------------------+ Handle "Not Found" | |
| | | | |
| | v | |
| | Array of [PSCustomObject] per service | |
| | | |
| | +--------------------+ Get-WinEvent (Application, System) | |
| | | Get-RecentErrors |--> Filter: Level 1,2 (Crit/Error) | |
| | +--------------------+ Last 24 hours, max 10 | |
| | | | |
| | v | |
| | Array of [PSCustomObject] per event | |
| | | |
| +------------------------------------------------------------------+ |
| | |
| v |
| Combine-AllData |
| (Create unified data structure for report) |
| | |
| v |
| Build-HTMLReport |
| (Generate complete HTML document) |
| | |
| v |
| [Output to file / browser / email] |
| |
+------------------------------------------------------------------------+
HTML Templating Approach
The report uses a Here-String template with variable interpolation:
$html = @"
<!DOCTYPE html>
<html>
<head>
<title>System Health - $($Metrics.ComputerName)</title>
<style>
$css
</style>
</head>
<body>
<h1>System Health Report</h1>
<p>Computer: <strong>$($Metrics.ComputerName)</strong> |
Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</p>
<div class="summary-section">
$summaryHtml
</div>
<h2>Disk Usage</h2>
$($DiskData | ConvertTo-Html -Fragment)
<h2>Services</h2>
$($ServiceData | ConvertTo-Html -Fragment)
<h2>Recent Errors</h2>
$errorsHtml
</body>
</html>
"@
Key Techniques:
- Here-Strings (
@"..."@): Multi-line strings with variable expansion - Subexpressions (
$(...)): Execute code within the string - ConvertTo-Html -Fragment: Generate just the
<table>portion - String replacement: Add CSS classes for styling after generation
6. Phased Implementation Guide
Phase 1: Basic WMI Queries (2-3 hours)
Goal: Query system information and display in console.
Learning Focus: Understanding CIM classes and their properties.
Steps:
- Create a new file
Get-SystemHealth.ps1 - Query
Win32_OperatingSystemfor memory and uptime - Query
Win32_Processorfor CPU info - Query
Win32_LogicalDiskfor disk space - Display raw values with
Write-Host
Starter Code:
#Requires -Version 5.1
# Get basic system information
$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor
Write-Host "=== System Information ===" -ForegroundColor Cyan
Write-Host "Computer: $($os.CSName)"
Write-Host "OS: $($os.Caption)"
Write-Host "Total Memory (KB): $($os.TotalVisibleMemorySize)"
Write-Host "Free Memory (KB): $($os.FreePhysicalMemory)"
Write-Host "Last Boot: $($os.LastBootUpTime)"
Write-Host ""
Write-Host "CPU: $($cpu.Name)"
Write-Host "Cores: $($cpu.NumberOfCores)"
Write-Host "Load: $($cpu.LoadPercentage)%"
Write-Host ""
# Get disk information
Write-Host "=== Disk Information ===" -ForegroundColor Cyan
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
Write-Host "Drive $($_.DeviceID): Size=$($_.Size), Free=$($_.FreeSpace)"
}
Verification: Run the script, see raw memory in KB and disk sizes in bytes.
Phase 2: Object Creation and Transformation (1-2 hours)
Goal: Convert raw values to human-readable format using custom objects.
Learning Focus: Calculated properties and PSCustomObject creation.
Steps:
- Create a function
Get-SystemMetricsthat returns a structured object - Convert memory from KB to GB
- Convert disk space from bytes to GB
- Calculate percentages (used/free)
- Format numbers with appropriate precision
Key Code:
function Get-SystemMetrics {
$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor
# Memory calculations (WMI returns KB, we want GB)
$totalMemoryGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$freeMemoryGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$usedMemoryGB = $totalMemoryGB - $freeMemoryGB
$memoryPercent = [math]::Round(($usedMemoryGB / $totalMemoryGB) * 100, 1)
# Uptime calculation
$uptime = (Get-Date) - $os.LastBootUpTime
$uptimeDays = [math]::Round($uptime.TotalDays, 1)
# Return structured object
[PSCustomObject]@{
ComputerName = $os.CSName
OSVersion = $os.Caption
CPUName = $cpu.Name
CPUCores = $cpu.NumberOfCores
CPULoad = $cpu.LoadPercentage
TotalMemoryGB = $totalMemoryGB
UsedMemoryGB = $usedMemoryGB
FreeMemoryGB = $freeMemoryGB
MemoryPercent = $memoryPercent
UptimeDays = $uptimeDays
LastBoot = $os.LastBootUpTime
}
}
# Test it
$metrics = Get-SystemMetrics
$metrics | Format-List
Verification: See “8.2 GB used” instead of “8589934592 KB”.
Phase 3: HTML Generation (2-3 hours)
Goal: Create a styled HTML report.
Learning Focus: ConvertTo-Html, CSS styling, Here-Strings.
Steps:
- Create CSS stylesheet for professional appearance
- Build HTML structure with sections
- Use
ConvertTo-Html -Fragmentfor data tables - Combine into complete HTML document
- Save to file
CSS Template:
$css = @"
body {
font-family: 'Segoe UI', Arial, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
border-bottom: 2px solid #4CAF50;
padding-bottom: 10px;
}
h2 {
color: #666;
margin-top: 30px;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 10px;
background-color: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
th {
background-color: #4CAF50;
color: white;
text-align: left;
padding: 12px;
}
td {
border: 1px solid #ddd;
padding: 10px;
}
tr:nth-child(even) { background-color: #f9f9f9; }
tr:hover { background-color: #f1f1f1; }
.critical { background-color: #f8d7da !important; color: #721c24; font-weight: bold; }
.warning { background-color: #fff3cd !important; color: #856404; font-weight: bold; }
.normal { background-color: #d4edda !important; color: #155724; }
.summary-card {
display: inline-block;
padding: 15px 25px;
margin: 10px;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.summary-value { font-size: 2em; font-weight: bold; color: #333; }
.summary-label { color: #666; margin-top: 5px; }
"@
Verification: Open HTML file in browser, see styled tables with data.
Phase 4: Styling and Parameters (1-2 hours)
Goal: Make script configurable and add status indicators.
Learning Focus: param() block, switches, status calculation.
Steps:
- Add
param()block with-OutputPathand-OpenInBrowser - Implement status threshold logic
- Add color-coding based on status
- Add comment-based help
Parameter Block:
#Requires -Version 5.1
<#
.SYNOPSIS
Generates a system health dashboard report.
.DESCRIPTION
Collects CPU, memory, disk, service, and error information
and generates an HTML report with color-coded status indicators.
.PARAMETER OutputPath
Path where the HTML report will be saved.
Default: $env:TEMP\SystemHealth_<timestamp>.html
.PARAMETER OpenInBrowser
If specified, opens the report in the default browser.
Default: $true
.PARAMETER EmailTo
Email address to send the report to.
.EXAMPLE
.\Get-SystemHealth.ps1
Generates report and opens in browser.
.EXAMPLE
.\Get-SystemHealth.ps1 -OutputPath "C:\Reports\health.html" -OpenInBrowser:$false
Generates report to specific path without opening.
#>
param(
[Parameter(Mandatory = $false)]
[string]$OutputPath = "$env:TEMP\SystemHealth_$(Get-Date -Format 'yyyyMMdd_HHmmss').html",
[Parameter(Mandatory = $false)]
[switch]$OpenInBrowser = $true,
[Parameter(Mandatory = $false)]
[string]$EmailTo
)
Status Calculation Function:
function Get-Status {
param(
[double]$Value,
[double]$WarningThreshold,
[double]$CriticalThreshold
)
if ($Value -ge $CriticalThreshold) {
return "CRITICAL"
}
elseif ($Value -ge $WarningThreshold) {
return "WARNING"
}
else {
return "NORMAL"
}
}
7. Testing Strategy
Unit Tests
| Test Case | Input | Expected Output | How to Verify |
|---|---|---|---|
| Get-SystemMetrics returns object | None | PSCustomObject with all properties | $m = Get-SystemMetrics; $m.GetType().Name -eq 'PSCustomObject' |
| Memory conversion accurate | Raw KB value | GB value / 1048576 | Compare manual calculation |
| CPU load retrieves value | None | Integer 0-100 or $null | $m.CPULoad -ge 0 -and $m.CPULoad -le 100 |
| Disk metrics returns array | None | Array of disk objects | (Get-DiskMetrics).Count -ge 1 |
| Status normal | 50%, thresholds 80/95 | “NORMAL” | Get-Status -Value 50 -Warning 80 -Critical 95 |
| Status warning | 85%, thresholds 80/95 | “WARNING” | Get-Status -Value 85 -Warning 80 -Critical 95 |
| Status critical | 96%, thresholds 80/95 | “CRITICAL” | Get-Status -Value 96 -Warning 80 -Critical 95 |
Integration Tests
| Test Case | Steps | Expected Result |
|---|---|---|
| Full report generation | Run .\Get-SystemHealth.ps1 |
HTML file created at default path |
| Custom output path | Run with -OutputPath "C:\test.html" |
File created at specified path |
| Browser auto-open | Run with -OpenInBrowser |
Default browser opens with report |
| No browser open | Run with -OpenInBrowser:$false |
Report saved but browser not opened |
| Service detection | Include existing and non-existing services | Existing shows status, non-existing shows “Not Installed” |
| Error log access | Run on system with errors | Errors displayed in report |
| Error log empty | Run on clean system | “No errors found” message |
Edge Cases
| Test Case | Scenario | Expected Handling |
|---|---|---|
| No local disks | Virtual machine with no local storage | Empty disk table or informative message |
| No admin rights | Standard user account | Complete with available data, skip restricted queries |
| WMI timeout | Slow system or corrupted WMI | Catch exception, return partial data |
| Very long uptime | System running 500+ days | Warning/Critical status correctly applied |
| 100% disk full | Drive at capacity | Critical status, no division by zero |
| No recent errors | No events matching criteria | “No errors in last 24 hours” message |
| Invalid output path | Non-existent directory | Create directory or show clear error |
Pester Test Example
Describe "Get-SystemHealth" {
BeforeAll {
. $PSScriptRoot\Get-SystemHealth.ps1
}
Context "Get-SystemMetrics" {
It "Returns a PSCustomObject" {
$result = Get-SystemMetrics
$result | Should -BeOfType [PSCustomObject]
}
It "Contains expected properties" {
$result = Get-SystemMetrics
$result.PSObject.Properties.Name | Should -Contain 'ComputerName'
$result.PSObject.Properties.Name | Should -Contain 'CPULoad'
$result.PSObject.Properties.Name | Should -Contain 'MemoryPercent'
}
It "Memory values are within valid range" {
$result = Get-SystemMetrics
$result.MemoryPercent | Should -BeGreaterOrEqual 0
$result.MemoryPercent | Should -BeLessOrEqual 100
}
}
Context "Get-Status" {
It "Returns NORMAL for values below warning" {
Get-Status -Value 50 -WarningThreshold 80 -CriticalThreshold 95 |
Should -Be "NORMAL"
}
It "Returns WARNING for values at or above warning" {
Get-Status -Value 80 -WarningThreshold 80 -CriticalThreshold 95 |
Should -Be "WARNING"
}
It "Returns CRITICAL for values at or above critical" {
Get-Status -Value 95 -WarningThreshold 80 -CriticalThreshold 95 |
Should -Be "CRITICAL"
}
}
}
8. Common Pitfalls and Debugging Tips
Problem: CIM Query Returns Nothing
Symptoms:
Get-CimInstancereturns empty result- No error message displayed
Possible Causes:
- Filter syntax error (wrong property name)
- Class name misspelled
- WMI repository corruption
- Querying remote machine without access
Solutions:
# 1. Test query without filter first
Get-CimInstance Win32_LogicalDisk # Does this return anything?
# 2. Verify class exists
Get-CimClass -ClassName Win32_LogicalDisk
# 3. Check available properties
Get-CimInstance Win32_LogicalDisk | Get-Member
# 4. If WMI is corrupted, rebuild repository (admin required)
# winmgmt /salvagerepository
# winmgmt /resetrepository
# 5. Try legacy WMI as fallback
Get-WmiObject Win32_LogicalDisk -Filter "DriveType=3"
Problem: HTML Doesn’t Render Correctly
Symptoms:
- Garbled text in browser
- Tables not styled
- Missing sections
Possible Causes:
- Unclosed HTML tags
- Special characters not escaped
- Encoding issues (UTF-8 vs. ANSI)
- Here-String syntax error
Solutions:
# 1. Validate HTML (open browser developer tools, check console for errors)
# 2. Escape special characters in data
[System.Web.HttpUtility]::HtmlEncode($unsafeString)
# Or in PowerShell 7+:
[System.Net.WebUtility]::HtmlEncode($unsafeString)
# 3. Always specify UTF-8 encoding
$html | Out-File -FilePath $path -Encoding UTF8
# 4. Check here-string syntax (no spaces before closing @")
$html = @"
content here
"@ # This line must start at column 0, no leading spaces
Problem: Event Log Access Denied
Symptoms:
Get-WinEventthrows “Access denied”- Only happens on certain logs
Possible Causes:
- Security log requires admin
- Other logs may have custom ACLs
- Remote event log access needs configuration
Solutions:
# 1. Wrap in try/catch and provide fallback
try {
$errors = Get-WinEvent -FilterHashtable @{
LogName = 'Application', 'System'
Level = 1, 2
StartTime = (Get-Date).AddHours(-24)
} -MaxEvents 10 -ErrorAction Stop
}
catch {
Write-Warning "Could not access event logs: $_"
$errors = @() # Return empty array
}
# 2. Check which logs you CAN access
Get-WinEvent -ListLog * | Where-Object { $_.RecordCount -gt 0 }
# 3. Document requirement: "Run as Administrator for full event access"
Problem: CPU Load Returns $null
Symptoms:
Win32_Processor.LoadPercentageis $null- Only on certain systems
Possible Causes:
- WMI performance counter not initialized
- First query after boot returns $null
- Multi-CPU systems return array
Solutions:
# 1. Query twice (first initializes, second returns value)
$null = Get-CimInstance Win32_Processor
Start-Sleep -Milliseconds 100
$cpu = Get-CimInstance Win32_Processor
# 2. Use performance counters instead (more reliable)
$cpuCounter = Get-Counter '\Processor(_Total)\% Processor Time'
$cpuLoad = [math]::Round($cpuCounter.CounterSamples.CookedValue, 1)
# 3. Handle array for multi-CPU (take average)
$cpus = Get-CimInstance Win32_Processor
$cpuLoad = ($cpus | Measure-Object -Property LoadPercentage -Average).Average
Problem: Script Takes Too Long
Symptoms:
- Script runs for 30+ seconds
- User perceives it as frozen
Possible Causes:
- WMI queries are slow
- Remote CIM sessions timing out
- Event log query returning too many results
Solutions:
# 1. Add progress indicators
Write-Host "Collecting CPU data..." -ForegroundColor Cyan
$cpu = Get-CimInstance Win32_Processor
# 2. Use -MaxEvents for event logs
Get-WinEvent -FilterHashtable @{...} -MaxEvents 10 # Limit results
# 3. Reduce event log time window
$startTime = (Get-Date).AddHours(-6) # Last 6 hours instead of 24
# 4. Consider parallel collection in PowerShell 7
$results = @()
@('Win32_OperatingSystem', 'Win32_Processor', 'Win32_LogicalDisk') |
ForEach-Object -Parallel {
Get-CimInstance $_
} -ThrottleLimit 3
Problem: File Path Issues
Symptoms:
- “Path not found” errors
- File saved in wrong location
- Special characters in path cause problems
Solutions:
# 1. Always use fully qualified paths
$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
# 2. Create parent directory if needed
$directory = [System.IO.Path]::GetDirectoryName($OutputPath)
if (-not (Test-Path $directory)) {
New-Item -ItemType Directory -Path $directory -Force
}
# 3. Handle paths with spaces
"$OutputPath" # Always quote variables
# 4. Validate path before using
if (-not (Test-Path -IsValid $OutputPath)) {
throw "Invalid path: $OutputPath"
}
9. Extensions and Challenges
Easy Extensions
| Extension | Description | Skills Practiced |
|---|---|---|
| Custom thresholds | Add -CPUWarning, -MemoryWarning parameters |
Parameter handling, validation |
| JSON output | Add -OutputFormat parameter (HTML/JSON) |
ConvertTo-Json, format switching |
| Timestamped filenames | Auto-generate unique names with date/time | String formatting, file handling |
| Custom service list | Read monitored services from config file | File I/O, configuration patterns |
| Dark mode CSS | Alternative styling for dark theme | CSS, parameter switches |
Medium Extensions
| Extension | Description | Skills Practiced |
|---|---|---|
| Historical trending | Store metrics over time, show graphs | Data storage, visualization |
| Scheduled execution | Create/manage Task Scheduler jobs | Windows API, scheduled tasks |
| Email on threshold | Only email when warnings/criticals exist | Conditional logic, SMTP |
| Multiple computers | Accept -ComputerName array parameter |
Remote CIM, error handling |
| Network metrics | Add network adapter utilization | Additional WMI classes |
| Process monitoring | Top 10 CPU/memory consuming processes | Process queries, sorting |
Advanced Extensions
| Extension | Description | Skills Practiced |
|---|---|---|
| PDF export | Convert HTML to PDF using Chromium | External tools, process management |
| Live dashboard | Web server with auto-refresh | HTTP listener, async |
| Multi-tenant | Central server collects from many machines | Remoting, aggregation |
| Grafana integration | Send metrics to Prometheus/Grafana | Metrics format, HTTP APIs |
| Alert webhooks | Send to Slack/Teams on critical | REST APIs, JSON payloads |
| Compare baselines | Compare current state to saved baseline | Data comparison, diffing |
Challenge Projects
Challenge 1: Predictive Disk Warning
- Track disk usage over time
- Calculate daily growth rate
- Predict when disk will fill up
- Alert X days before capacity
Challenge 2: Dependency Service Monitoring
- Query service dependencies
- Build dependency graph
- Warn if critical dependency is stopped
- Visualize in HTML
Challenge 3: Cross-Platform Health
- Make script work on Linux (PowerShell Core)
- Abstract OS-specific queries
- Use
/procfilesystem on Linux - Unified output format
10. Books That Will Help
| Topic | Book | Relevant Chapter | Why It Helps |
|---|---|---|---|
| WMI/CIM fundamentals | Learn PowerShell in a Month of Lunches (4th Ed.) by Travis Plunk & James Petty | Ch. 8: “Objects: Data by another name” | Explains object-based thinking and WMI queries |
| Pipeline mastery | Learn PowerShell in a Month of Lunches | Ch. 9: “The pipeline: Connecting commands” | Deep dive into pipeline mechanics |
| Where/Select/ForEach | Learn PowerShell in a Month of Lunches | Ch. 10: “Filtering and comparisons” | Filtering and projection patterns |
| Error handling | Learn PowerShell in a Month of Lunches | Ch. 18: “Error handling” | Try/catch/finally best practices |
| Advanced functions | Windows PowerShell in Action (3rd Ed.) by Bruce Payette | Ch. 7: “PowerShell functions” | Parameter blocks, validation, help |
| CIM deep dive | Windows PowerShell in Action | Ch. 19: “Management objects: WMI and CIM” | Comprehensive WMI/CIM coverage |
| Practical recipes | The PowerShell Cookbook (4th Ed.) by Lee Holmes | Various sections | Ready-to-use solutions for common tasks |
| HTML/report generation | PowerShell for Sysadmins by Adam Bertram | Ch. 10: “Building Reusable Tools” | Creating professional output |
| Remoting | Learn PowerShell in a Month of Lunches | Ch. 11-13 | Remote execution for multi-machine |
| Module development | The Pester Book by Adam Bertram | Testing chapters | Writing testable, modular code |
Recommended Reading Order
- Start with: “Learn PowerShell in a Month of Lunches” - Ch. 8, 9, 10
- Foundation for understanding objects and pipelines
- Then: “Learn PowerShell in a Month of Lunches” - Ch. 18
- Error handling is critical for robust scripts
- Reference as needed: “The PowerShell Cookbook”
- Look up specific techniques when you need them
- For deeper understanding: “Windows PowerShell in Action” - Ch. 19
- Comprehensive WMI/CIM reference
11. Self-Assessment Checklist
Before considering this project complete, verify each item:
Core Functionality
- Script queries CPU usage from Win32_Processor
- Script queries memory usage from Win32_OperatingSystem
- Script queries disk space from Win32_LogicalDisk (local drives only)
- System uptime is calculated correctly from LastBootUpTime
- Values are converted to human-readable format (GB, percentages)
- Status thresholds work correctly (NORMAL/WARNING/CRITICAL)
HTML Report
- HTML report is generated with valid structure
- CSS styling is applied (tables, colors, layout)
- Status values are color-coded (green/yellow/red)
- Summary cards show key metrics at a glance
- Report includes timestamp and computer name
- Report opens correctly in major browsers (Chrome, Edge, Firefox)
Additional Features
- Service status section shows configured services
- Missing services are handled gracefully (“Not Installed”)
- Recent errors section shows last 24 hours of errors
- Empty error log shows appropriate message
Parameters and Robustness
-OutputPathparameter works for custom save location-OpenInBrowserswitch controls browser auto-open- Script handles errors gracefully with try/catch
- Script works without administrator privileges (limited data)
- Script completes in under 10 seconds
- Comment-based help is included (.SYNOPSIS, .EXAMPLE)
Code Quality
- Code is organized into logical functions
- Variable and function names are descriptive
- Magic numbers are replaced with named variables/thresholds
- Code follows PowerShell naming conventions (Verb-Noun)
- No hardcoded paths (uses $env:TEMP, parameters)
Edge Cases
- Handles systems with single CPU
- Handles systems with multiple CPUs
- Handles disks at 100% capacity without errors
- Handles systems with no recent errors
- Works on Windows 10 and Windows 11
- Works with PowerShell 5.1 and PowerShell 7+
Complete Solution Reference
Below is a complete, production-ready implementation that you can reference after attempting each phase yourself.
Full Script: Get-SystemHealth.ps1
#Requires -Version 5.1
<#
.SYNOPSIS
Generates a system health dashboard report.
.DESCRIPTION
Collects CPU, memory, disk, service, and error information
and generates an HTML report with color-coded status indicators.
.PARAMETER OutputPath
Path where the HTML report will be saved.
Default: $env:TEMP\SystemHealth_<timestamp>.html
.PARAMETER OpenInBrowser
If specified, opens the report in the default browser.
Default: $true
.PARAMETER EmailTo
Email address to send the report to (not implemented in basic version).
.EXAMPLE
.\Get-SystemHealth.ps1
Generates report and opens in browser.
.EXAMPLE
.\Get-SystemHealth.ps1 -OutputPath "C:\Reports\health.html" -OpenInBrowser:$false
Generates report to specific path without opening.
#>
param(
[Parameter(Mandatory = $false)]
[string]$OutputPath = "$env:TEMP\SystemHealth_$(Get-Date -Format 'yyyyMMdd_HHmmss').html",
[Parameter(Mandatory = $false)]
[switch]$OpenInBrowser = $true,
[Parameter(Mandatory = $false)]
[string]$EmailTo
)
#region Configuration
$Thresholds = @{
CPU = @{
Warning = 80
Critical = 95
}
Memory = @{
Warning = 75
Critical = 90
}
Disk = @{
Warning = 80
Critical = 95
}
Uptime = @{
Warning = 90
Critical = 180
}
}
$CriticalServices = @(
'wuauserv', # Windows Update
'WinDefend', # Windows Defender
'EventLog', # Windows Event Log
'Spooler', # Print Spooler
'MSSQLSERVER' # SQL Server (if installed)
)
#endregion
#region Helper Functions
function Get-Status {
param(
[double]$Value,
[double]$WarningThreshold,
[double]$CriticalThreshold
)
if ($Value -ge $CriticalThreshold) {
return "CRITICAL"
}
elseif ($Value -ge $WarningThreshold) {
return "WARNING"
}
else {
return "NORMAL"
}
}
function Get-SystemMetrics {
$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor
# Memory calculations (WMI returns KB)
$totalMemoryGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$freeMemoryGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$usedMemoryGB = $totalMemoryGB - $freeMemoryGB
$memoryPercent = [math]::Round(($usedMemoryGB / $totalMemoryGB) * 100, 1)
# Uptime calculation
$uptime = (Get-Date) - $os.LastBootUpTime
$uptimeDays = [math]::Round($uptime.TotalDays, 1)
# Handle multi-CPU systems
$cpuLoad = if ($cpu -is [array]) {
($cpu | Measure-Object -Property LoadPercentage -Average).Average
}
else {
$cpu.LoadPercentage
}
$cpuLoad = if ($null -eq $cpuLoad) { 0 } else { [int]$cpuLoad }
$cpuName = if ($cpu -is [array]) { $cpu[0].Name } else { $cpu.Name }
$cpuCores = if ($cpu -is [array]) {
($cpu | Measure-Object -Property NumberOfCores -Sum).Sum
}
else {
$cpu.NumberOfCores
}
[PSCustomObject]@{
ComputerName = $os.CSName
OSVersion = $os.Caption
CPUName = $cpuName.Trim()
CPUCores = $cpuCores
CPULoad = $cpuLoad
CPUStatus = Get-Status -Value $cpuLoad -WarningThreshold $Thresholds.CPU.Warning -CriticalThreshold $Thresholds.CPU.Critical
TotalMemoryGB = $totalMemoryGB
UsedMemoryGB = $usedMemoryGB
FreeMemoryGB = $freeMemoryGB
MemoryPercent = $memoryPercent
MemoryStatus = Get-Status -Value $memoryPercent -WarningThreshold $Thresholds.Memory.Warning -CriticalThreshold $Thresholds.Memory.Critical
UptimeDays = $uptimeDays
UptimeStatus = Get-Status -Value $uptimeDays -WarningThreshold $Thresholds.Uptime.Warning -CriticalThreshold $Thresholds.Uptime.Critical
LastBoot = $os.LastBootUpTime
}
}
function Get-DiskMetrics {
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
$sizeGB = [math]::Round($_.Size / 1GB, 2)
$freeGB = [math]::Round($_.FreeSpace / 1GB, 2)
$usedGB = $sizeGB - $freeGB
$usedPercent = if ($sizeGB -gt 0) {
[math]::Round(($usedGB / $sizeGB) * 100, 1)
}
else { 0 }
[PSCustomObject]@{
Drive = $_.DeviceID
Label = $_.VolumeName
SizeGB = $sizeGB
UsedGB = $usedGB
FreeGB = $freeGB
UsedPercent = $usedPercent
Status = Get-Status -Value $usedPercent -WarningThreshold $Thresholds.Disk.Warning -CriticalThreshold $Thresholds.Disk.Critical
}
}
}
function Get-ServiceStatus {
$results = @()
foreach ($serviceName in $CriticalServices) {
try {
$service = Get-Service -Name $serviceName -ErrorAction Stop
$status = if ($service.Status -eq 'Running') {
"NORMAL"
}
else {
"WARNING"
}
$results += [PSCustomObject]@{
Name = $service.DisplayName
ServiceName = $service.Name
State = $service.Status.ToString()
HealthStatus = $status
}
}
catch {
$results += [PSCustomObject]@{
Name = $serviceName
ServiceName = $serviceName
State = "Not Installed"
HealthStatus = "INFO"
}
}
}
return $results
}
function Get-RecentErrors {
param(
[int]$Hours = 24,
[int]$MaxEvents = 10
)
$startTime = (Get-Date).AddHours(-$Hours)
try {
Get-WinEvent -FilterHashtable @{
LogName = 'Application', 'System'
Level = 1, 2 # Critical = 1, Error = 2
StartTime = $startTime
} -MaxEvents $MaxEvents -ErrorAction Stop | ForEach-Object {
$msgLength = if ($_.Message.Length -gt 100) { 100 } else { $_.Message.Length }
[PSCustomObject]@{
Time = $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss")
Level = $_.LevelDisplayName
Source = $_.ProviderName
Message = $_.Message.Substring(0, $msgLength) + "..."
}
}
}
catch {
Write-Warning "Could not retrieve event logs: $_"
return @()
}
}
function New-HTMLReport {
param(
[object]$SystemMetrics,
[object[]]$DiskMetrics,
[object[]]$ServiceStatus,
[object[]]$RecentErrors
)
$css = @"
<style>
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f0f2f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #2c3e50;
border-bottom: 3px solid #3498db;
padding-bottom: 10px;
margin-bottom: 5px;
}
.timestamp {
color: #7f8c8d;
margin-bottom: 20px;
}
h2 {
color: #34495e;
margin-top: 30px;
padding-bottom: 5px;
border-bottom: 1px solid #bdc3c7;
}
.summary-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin: 20px 0;
}
.summary-card {
background: white;
border-radius: 10px;
padding: 20px 30px;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
min-width: 150px;
flex: 1;
}
.summary-value {
font-size: 2.5em;
font-weight: bold;
color: #2c3e50;
}
.summary-label {
color: #7f8c8d;
margin-top: 5px;
font-size: 0.9em;
}
.summary-status {
margin-top: 10px;
padding: 3px 10px;
border-radius: 15px;
font-size: 0.8em;
font-weight: bold;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 15px;
background-color: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
th {
background-color: #3498db;
color: white;
text-align: left;
padding: 15px;
font-weight: 600;
}
td {
border-bottom: 1px solid #ecf0f1;
padding: 12px 15px;
}
tr:last-child td { border-bottom: none; }
tr:hover { background-color: #f8f9fa; }
.CRITICAL, .critical {
background-color: #fadbd8 !important;
color: #922b21;
font-weight: bold;
}
.WARNING, .warning {
background-color: #fdebd0 !important;
color: #9c640c;
font-weight: bold;
}
.NORMAL, .normal {
background-color: #d5f5e3 !important;
color: #1e8449;
}
.INFO, .info {
background-color: #d6eaf8 !important;
color: #1a5276;
}
.info-table {
width: 100%;
}
.info-table td:first-child {
font-weight: 600;
width: 200px;
color: #7f8c8d;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #bdc3c7;
text-align: center;
color: #95a5a6;
font-size: 0.9em;
}
</style>
"@
# Summary cards
$cpuStatusClass = $SystemMetrics.CPUStatus.ToLower()
$memStatusClass = $SystemMetrics.MemoryStatus.ToLower()
$uptimeStatusClass = $SystemMetrics.UptimeStatus.ToLower()
$summaryHtml = @"
<div class="summary-container">
<div class="summary-card">
<div class="summary-value">$($SystemMetrics.CPULoad)%</div>
<div class="summary-label">CPU Usage</div>
<div class="summary-status $cpuStatusClass">$($SystemMetrics.CPUStatus)</div>
</div>
<div class="summary-card">
<div class="summary-value">$($SystemMetrics.MemoryPercent)%</div>
<div class="summary-label">Memory Used</div>
<div class="summary-status $memStatusClass">$($SystemMetrics.MemoryStatus)</div>
</div>
<div class="summary-card">
<div class="summary-value">$($SystemMetrics.UptimeDays)</div>
<div class="summary-label">Days Uptime</div>
<div class="summary-status $uptimeStatusClass">$($SystemMetrics.UptimeStatus)</div>
</div>
</div>
"@
# System info table
$sysInfoHtml = @"
<table class="info-table">
<tr><td>Operating System</td><td>$($SystemMetrics.OSVersion)</td></tr>
<tr><td>CPU</td><td>$($SystemMetrics.CPUName) ($($SystemMetrics.CPUCores) cores)</td></tr>
<tr><td>Total Memory</td><td>$($SystemMetrics.TotalMemoryGB) GB</td></tr>
<tr><td>Used Memory</td><td>$($SystemMetrics.UsedMemoryGB) GB ($($SystemMetrics.MemoryPercent)%)</td></tr>
<tr><td>Last Boot</td><td>$($SystemMetrics.LastBoot.ToString('yyyy-MM-dd HH:mm:ss'))</td></tr>
</table>
"@
# Disk table
$diskHtml = $DiskMetrics |
Select-Object Drive, Label,
@{Name = 'Size (GB)'; Expression = { $_.SizeGB } },
@{Name = 'Used (GB)'; Expression = { $_.UsedGB } },
@{Name = 'Free (GB)'; Expression = { $_.FreeGB } },
@{Name = 'Used %'; Expression = { "$($_.UsedPercent)%" } },
Status |
ConvertTo-Html -Fragment
$diskHtml = $diskHtml -replace '<td>CRITICAL</td>', '<td class="CRITICAL">CRITICAL</td>'
$diskHtml = $diskHtml -replace '<td>WARNING</td>', '<td class="WARNING">WARNING</td>'
$diskHtml = $diskHtml -replace '<td>NORMAL</td>', '<td class="NORMAL">NORMAL</td>'
# Service table
$serviceHtml = $ServiceStatus |
Select-Object Name, State, HealthStatus |
ConvertTo-Html -Fragment
$serviceHtml = $serviceHtml -replace '<td>CRITICAL</td>', '<td class="CRITICAL">CRITICAL</td>'
$serviceHtml = $serviceHtml -replace '<td>WARNING</td>', '<td class="WARNING">WARNING</td>'
$serviceHtml = $serviceHtml -replace '<td>NORMAL</td>', '<td class="NORMAL">NORMAL</td>'
$serviceHtml = $serviceHtml -replace '<td>INFO</td>', '<td class="INFO">INFO</td>'
# Errors section
$errorsHtml = if ($RecentErrors.Count -gt 0) {
$RecentErrors | ConvertTo-Html -Fragment
}
else {
"<p style='color: #27ae60; font-weight: bold;'>No errors found in the last 24 hours.</p>"
}
# Complete HTML
$html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Health Report - $($SystemMetrics.ComputerName)</title>
$css
</head>
<body>
<div class="container">
<h1>System Health Report</h1>
<p class="timestamp">
<strong>Computer:</strong> $($SystemMetrics.ComputerName) |
<strong>Generated:</strong> $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
</p>
$summaryHtml
<h2>System Information</h2>
$sysInfoHtml
<h2>Disk Usage</h2>
$diskHtml
<h2>Critical Services</h2>
$serviceHtml
<h2>Recent Errors (Last 24 Hours)</h2>
$errorsHtml
<div class="footer">
<p>Report generated by Get-SystemHealth.ps1 | PowerShell $($PSVersionTable.PSVersion)</p>
</div>
</div>
</body>
</html>
"@
return $html
}
#endregion
#region Main Execution
try {
Write-Host "Collecting system metrics..." -ForegroundColor Cyan
Write-Host " [..] CPU and Memory" -NoNewline
$systemMetrics = Get-SystemMetrics
Write-Host "`r [OK] CPU and Memory" -ForegroundColor Green
Write-Host " [..] Disk Information" -NoNewline
$diskMetrics = Get-DiskMetrics
Write-Host "`r [OK] Disk Information" -ForegroundColor Green
Write-Host " [..] Service Status" -NoNewline
$serviceStatus = Get-ServiceStatus
Write-Host "`r [OK] Service Status" -ForegroundColor Green
Write-Host " [..] Recent Errors" -NoNewline
$recentErrors = Get-RecentErrors
$errorCount = if ($recentErrors) { $recentErrors.Count } else { 0 }
Write-Host "`r [OK] Recent Errors ($errorCount found)" -ForegroundColor Green
Write-Host "Generating HTML report..." -ForegroundColor Cyan
$html = New-HTMLReport -SystemMetrics $systemMetrics `
-DiskMetrics $diskMetrics `
-ServiceStatus $serviceStatus `
-RecentErrors $recentErrors
# Ensure output directory exists
$outputDir = [System.IO.Path]::GetDirectoryName($OutputPath)
if ($outputDir -and -not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
# Save to file
$html | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Host "Report saved to: $OutputPath" -ForegroundColor Green
# Open in browser if requested
if ($OpenInBrowser) {
Write-Host "Opening in browser..." -ForegroundColor Cyan
Start-Process $OutputPath
}
# Return the path for script consumers
return $OutputPath
}
catch {
Write-Error "Error generating report: $_"
exit 1
}
#endregion
Interview Preparation
Questions You Should Be Able to Answer
1. “What’s the difference between WMI and CIM?”
CIM (Common Information Model) is the modern standard for querying Windows system information, using WS-Man protocol (same as WinRM). WMI is the legacy implementation using DCOM. CIM is faster, more secure, and firewall-friendly. Always use Get-CimInstance for new scripts; Get-WmiObject is deprecated in PowerShell Core.
2. “Explain how the PowerShell pipeline works.”
PowerShell’s pipeline passes objects, not text. Each cmdlet receives objects from the previous cmdlet and emits objects to the next. Objects have typed properties accessed by name, not position. This enables filtering (Where-Object), projection (Select-Object), and transformation without string parsing.
3. “How would you handle errors in this script?”
Use try/catch/finally blocks around operations that might fail. Set -ErrorAction Stop on cmdlets to make non-terminating errors catchable. Provide meaningful fallback behavior (empty arrays, default values) when queries fail. Log warnings for partial failures but continue execution.
4. “How would you scale this to 100 servers?”
Use Invoke-Command -ComputerName $servers -ScriptBlock {...} for parallel remote execution. Implement -ComputerName parameter accepting an array. Handle individual server failures gracefully without stopping the entire collection. Aggregate results and generate a combined report or individual reports per server.
5. “Why use calculated properties instead of transforming data afterward?”
Calculated properties transform data at query time, reducing memory usage and processing steps. They make the pipeline self-documenting. They allow renaming properties for cleaner output. The alternative (ForEach-Object transformation) works but is more verbose and separates the transformation logic from the query.