PowerShell IT Support Diagnostic Module
Build a PowerShell module that automates IT diagnostics and generates HTML reports.
Introduction
30 Second Summary
When something breaks on a corporate network, IT support teams race to figure out what went wrong. Manually checking connections, services, and disk space one at a time is slow and leaves room for human error.
In this project, you will build a reusable PowerShell module that automates network and system diagnostics, classifies each finding by incident severity, and generates a professional HTML report you could attach directly to a support ticket.
What You'll Build
A production-grade PowerShell module called SupportDiagnostics that runs automated health checks, classifies results by P1-P4 severity with SLA deadlines, and outputs a ticket-ready HTML diagnostic report.
By the end of this project, you'll have:
- A PowerShell module with a manifest (.psd1) and root script (.psm1) exporting 5 advanced functions that compose together through the pipeline.
- An incident classification engine that auto-categorizes diagnostic findings into P1-Critical through P4-Low severity levels with calculated SLA deadlines.
- A professional HTML diagnostic report with severity color-coding, summary cards, and a sortable results table ready for escalation handoff.
- Secret Mission: Add automated remediation with ShouldProcess confirmation prompts that can restart failed services and clear DNS cache.
Are there any prerequisites?
You should be familiar with PowerShell scripting including functions, parameters, and the pipeline. Basic knowledge of networking concepts (DNS, TCP ports, ICMP) and Windows services is helpful.
Set Up the Module Structure
A PowerShell module is a reusable package of functions that you can import into any session with a single command. Instead of copying and pasting diagnostic scripts between machines, a module lets you distribute your toolkit as a versioned, self-contained unit.
In this step, you will create the folder structure for your module, generate a module manifest that declares which functions the module exports, and confirm that PowerShell recognizes it.
In this step, get ready to:
- Create the SupportDiagnostics folder with its two module files.
- Configure the module manifest with version, function exports, and metadata.
- Import the module and verify all 5 functions are available.
Create the module folder structure
Every PowerShell module lives inside a folder whose name matches the module name. The folder holds a manifest file (.psd1) and a script module file (.psm1) where your function code will go.
- Open the integrated terminal in VS Code by pressing Ctrl+`.
- Move to your Desktop by running this command:
cd ~\Desktop
- Create the module folder and move into it by running these commands:
mkdir SupportDiagnostics
cd SupportDiagnostics
- Create the empty root module file by running this command:
New-Item -Name SupportDiagnostics.psm1 -ItemType File
What do these files do?
The .psm1 file is where your function code lives. You will fill it in over the next steps.
The .psd1 manifest file tells PowerShell what the module is called, which version it is, and which functions it exposes. You will create this next.
- Confirm both files exist by running:
ls
You should see SupportDiagnostics.psm1 listed in the output.
Don't see the file?
Make sure you ran the mkdir and cd commands first. If your prompt does not show SupportDiagnostics as the current directory, run cd ~\Desktop\SupportDiagnostics and try again.
Help me troubleshoot my module folder structure.
Create the module manifest
The New-ModuleManifest cmdlet generates a manifest file pre-populated with the settings you pass in. You will use it to declare the module version, the minimum PowerShell version, and the exact list of functions this module exports.
- Generate the manifest by running this command:
New-ModuleManifest -Path .\SupportDiagnostics.psd1 `
-RootModule 'SupportDiagnostics.psm1' `
-ModuleVersion '1.0.0' `
-GUID 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' `
-Author 'Support Engineer' `
-Description 'IT Support Diagnostic Toolkit for automated system triage and incident reporting' `
-PowerShellVersion '5.1' `
-FunctionsToExport @('Get-NetworkDiagnostic','Get-SystemHealth','Get-IncidentSeverity','Export-DiagnosticReport','Invoke-SupportDiagnostic') `
-CmdletsToExport @() `
-VariablesToExport @() `
-AliasesToExport @()
What does this command do?
-RootModule tells PowerShell which .psm1 file contains the function code. -FunctionsToExport restricts the module to only expose these 5 named functions, even if additional helper functions exist inside the file.
Setting -CmdletsToExport, -VariablesToExport, and -AliasesToExport to empty arrays ensures nothing leaks out of the module except the functions you explicitly listed.
The generated file contains dozens of commented-out options you do not need. You will replace its contents with a streamlined version that keeps only the essentials.
- Open SupportDiagnostics.psd1 in VS Code by running:
code .\SupportDiagnostics.psd1
- Select all text in the file with Ctrl+A, then delete it.
- Paste the following clean manifest in its place:
@{
RootModule = 'SupportDiagnostics.psm1'
ModuleVersion = '1.0.0'
GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
Author = 'Support Engineer'
Description = 'IT Support Diagnostic Toolkit for automated system triage and incident reporting'
PowerShellVersion = '5.1'
FunctionsToExport = @(
'Get-NetworkDiagnostic',
'Get-SystemHealth',
'Get-IncidentSeverity',
'Export-DiagnosticReport',
'Invoke-SupportDiagnostic'
)
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
}
Why replace the generated file?
New-ModuleManifest produces a verbose file with comments for every possible option. In production, teams prefer a minimal manifest that shows exactly what is configured without scrolling through boilerplate.
The clean version keeps the same settings you passed to the command. It just removes everything that is not actively used.
- Save SupportDiagnostics.psd1 with Ctrl+S.
Seeing a parse error when you save?
Make sure you copied the entire block starting with @{ and ending with the closing }. A missing curly brace or misplaced quote will cause PowerShell to reject the manifest.
Help me fix a parse error in my module manifest.
Verify the module loads correctly
Importing the module tells PowerShell to read the manifest and register the exported functions in your session.
- Switch back to your terminal and import the module by running:
Import-Module .\SupportDiagnostics.psd1
- Confirm all 5 functions are available by running:
Get-Command -Module SupportDiagnostics
What should I see?
The output lists 5 commands: Export-DiagnosticReport, Get-IncidentSeverity, Get-NetworkDiagnostic, Get-SystemHealth, and Invoke-SupportDiagnostic. Each shows CommandType as Function.
These functions exist but contain no code yet. You will build them in the upcoming steps.
Getting an error on Import-Module?
- If you see a message about the file not being found, confirm your terminal is inside the SupportDiagnostics folder by running pwd.
- If you see a message about the manifest being invalid, open SupportDiagnostics.psd1 and verify it starts with @{ and ends with } on its own line.
- If you previously imported the module and made changes, run Remove-Module SupportDiagnostics first, then re-import.
Help me fix my module import error.
✔️ Awesome, I've got everything!
Great. Make sure you have saved SupportDiagnostics.psd1 before moving on.
ⓧ I'd like to double check the full code
Here is the complete SupportDiagnostics.psd1 file you should have at this point:
@{
RootModule = 'SupportDiagnostics.psm1'
ModuleVersion = '1.0.0'
GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
Author = 'Support Engineer'
Description = 'IT Support Diagnostic Toolkit for automated system triage and incident reporting'
PowerShellVersion = '5.1'
FunctionsToExport = @(
'Get-NetworkDiagnostic',
'Get-SystemHealth',
'Get-IncidentSeverity',
'Export-DiagnosticReport',
'Invoke-SupportDiagnostic'
)
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
}
Your SupportDiagnostics.psm1 file should be empty for now. You will add function code starting in the next step.
Your module skeleton is in place. Next up, you will write the first real function that runs automated network diagnostics.
Build the Network Diagnostics Function
Your module structure is in place with a manifest and an empty script file ready for code. Now it's time to build the first function that performs real diagnostic work on a network.
When a support ticket reports connectivity issues, the engineer needs to quickly determine if the problem is an unreachable host, a blocked port, or a DNS resolution failure. You'll automate those checks with the Get-NetworkDiagnostic function.
In this step, get ready to:
- Write an advanced function with CmdletBinding and typed parameters.
- Implement connectivity, port scan, and DNS resolution checks.
- Test the function and verify structured output.
Write the function declaration and parameters
An advanced function in PowerShell uses the [CmdletBinding()] attribute to gain cmdlet-like behaviour. This gives you access to common parameters like -Verbose and -ErrorAction for free.
- Open SupportDiagnostics.psm1 in VS Code (it should be empty).
- Paste the following code to define the function signature, parameters, and begin block:
function Get-NetworkDiagnostic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$Targets = @('8.8.8.8', '1.1.1.1', 'microsoft.com'),
[Parameter(Mandatory = $false)]
[int[]]$Ports = @(80, 443, 53, 3389),
[Parameter(Mandatory = $false)]
[string[]]$DnsNames = @('microsoft.com', 'google.com')
)
begin {
$results = [System.Collections.ArrayList]::new()
Write-Verbose "Starting network diagnostics..."
}
What does this code do?
[CmdletBinding()] promotes a regular function into an advanced function. This unlocks common parameters that every built-in cmdlet supports.
- $Targets is a string array of hosts to ping. The defaults cover Google DNS, Cloudflare DNS, and Microsoft's domain.
- $Ports is an int array of TCP ports to probe (HTTP, HTTPS, DNS, RDP).
- $DnsNames is a string array of domains to resolve via DNS.
- The begin block initialises an ArrayList to collect results and writes a verbose status message.
Don't save yet. You'll add the process and end blocks next.
Implement the three diagnostic checks
The process block is where the function does its real work. You'll run three categories of checks: ICMP connectivity, TCP port scanning, and DNS resolution.
- Directly below the closing } of the begin block, paste the connectivity check code:
process {
foreach ($target in $Targets) {
$test = Test-NetConnection -ComputerName $target -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'Connectivity'
Target = $target
Result = if ($test.PingSucceeded) { 'Reachable' } else { 'Unreachable' }
Status = if ($test.PingSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
What does this code do?
Test-NetConnection sends an ICMP ping to each target. The -WarningAction SilentlyContinue parameter suppresses the warning message that appears when a ping fails.
Each result is a PSCustomObject with five properties: CheckType, Target, Result, Status, and Timestamp. This consistent shape makes the objects easy to filter, classify, and report on later.
- Directly below the connectivity foreach loop, paste the TCP port scan code:
foreach ($port in $Ports) {
$test = Test-NetConnection -ComputerName $Targets[0] -Port $port -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'PortScan'
Target = "$($Targets[0]):$port"
Result = if ($test.TcpTestSucceeded) { 'Open' } else { 'Closed' }
Status = if ($test.TcpTestSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
What does this code do?
This loop uses the -Port parameter of Test-NetConnection to attempt a TCP handshake on each port against the first target. The TcpTestSucceeded property tells you whether the port accepted the connection.
- Below the port scan loop, paste the DNS resolution check and close the process block:
foreach ($name in $DnsNames) {
try {
$dns = Resolve-DnsName -Name $name -Type A -ErrorAction Stop
$resolved = ($dns | Where-Object { $_.Type -eq 'A' } | Select-Object -First 1).IPAddress
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = $resolved
Status = 'Pass'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = "Resolution failed: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
}
What does this code do?
Resolve-DnsName queries DNS for an A record. The -ErrorAction Stop converts non-terminating errors into terminating exceptions so the catch block can handle them.
If resolution succeeds, the first A record IP address is captured. If it fails, the exception message is stored as the result with a Fail status. This structured error handling means no single DNS failure can crash the entire diagnostic run.
- Finally, paste the end block and closing brace to complete the function:
end {
Write-Verbose "Network diagnostics complete. $($results.Count) checks performed."
return $results
}
}
What does the end block do?
The end block runs once after all pipeline input has been processed. It writes a verbose summary and returns the full results collection to the caller.
- Save SupportDiagnostics.psm1.
Seeing red squiggles in VS Code?
Make sure every opening { has a matching closing }. The function has nested braces for param(), begin, process, end, each foreach loop, and the try/catch block. Use the double-check tab below to compare your file against the full code.
Help me debug brace-matching issues in my Get-NetworkDiagnostic function.
✔️ Awesome, I've got everything!
Great. Make sure you've saved SupportDiagnostics.psm1 before continuing.
ⓧ I'd like to double check the full code
function Get-NetworkDiagnostic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$Targets = @('8.8.8.8', '1.1.1.1', 'microsoft.com'),
[Parameter(Mandatory = $false)]
[int[]]$Ports = @(80, 443, 53, 3389),
[Parameter(Mandatory = $false)]
[string[]]$DnsNames = @('microsoft.com', 'google.com')
)
begin {
$results = [System.Collections.ArrayList]::new()
Write-Verbose "Starting network diagnostics..."
}
process {
foreach ($target in $Targets) {
$test = Test-NetConnection -ComputerName $target -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'Connectivity'
Target = $target
Result = if ($test.PingSucceeded) { 'Reachable' } else { 'Unreachable' }
Status = if ($test.PingSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
foreach ($port in $Ports) {
$test = Test-NetConnection -ComputerName $Targets[0] -Port $port -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'PortScan'
Target = "$($Targets[0]):$port"
Result = if ($test.TcpTestSucceeded) { 'Open' } else { 'Closed' }
Status = if ($test.TcpTestSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
foreach ($name in $DnsNames) {
try {
$dns = Resolve-DnsName -Name $name -Type A -ErrorAction Stop
$resolved = ($dns | Where-Object { $_.Type -eq 'A' } | Select-Object -First 1).IPAddress
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = $resolved
Status = 'Pass'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = "Resolution failed: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
}
end {
Write-Verbose "Network diagnostics complete. $($results.Count) checks performed."
return $results
}
}
Test the function
With the function saved, you can re-import the module and run the diagnostics to confirm everything works.
- In your PowerShell terminal, re-import the module with the -Force flag and run the function by executing:
Import-Module .\SupportDiagnostics.psd1 -Force
Get-NetworkDiagnostic -Verbose
Why use -Force when importing?
PowerShell caches module definitions in the current session. The -Force parameter tells Import-Module to reload the module from disk, picking up any code changes you just saved.
- Wait for the checks to complete. Each Test-NetConnection call takes a few seconds, so the full run may take 30-60 seconds.
- Verify that the output shows an array of objects with CheckType, Target, Result, Status, and Timestamp properties.
- Confirm you see verbose messages including "Starting network diagnostics..." and "Network diagnostics complete."
Command takes forever or hangs?
Test-NetConnection always performs a ping even for TCP port tests, and each unreachable target or blocked port can take several seconds to time out. If your network blocks ICMP, the connectivity checks will take longer but will still return results.
If the command seems stuck, wait at least 60 seconds. Port 3389 (RDP) is commonly blocked on home networks, which adds timeout delay.
Help me troubleshoot why Get-NetworkDiagnostic is taking a long time to run.
Seeing a 'not recognized' error?
Make sure your terminal's working directory is the SupportDiagnostics folder. The .\SupportDiagnostics.psd1 path is relative, so it only works from inside that folder.
Also confirm the function name is spelled exactly Get-NetworkDiagnostic in your .psm1 file. It must match the name in the manifest's FunctionsToExport array.
Help me fix a 'not recognized' error when running Get-NetworkDiagnostic.
Your network diagnostic function is working. Next up, you'll build the system health function that checks Windows services, disk space, and event logs.
Build the System Health Function
Network connectivity is only half the picture when triaging a support ticket. A machine can reach the internet but still have a failed service, a nearly-full disk, or a flood of system errors.
In this step, you will build Get-SystemHealth to check critical Windows services, disk space, and the System event log. Each check produces the same PSCustomObject shape as your network function, so the results can flow through the same pipeline later.
In this step, get ready to:
- Write the Get-SystemHealth advanced function with CmdletBinding and three parameters.
- Implement service, disk, and event log health checks with structured error handling.
- Test the function and verify it returns diagnostic objects.
Write the Get-SystemHealth function declaration
This function follows the same advanced function pattern as Get-NetworkDiagnostic. It uses CmdletBinding, three parameters with sensible defaults, and the begin/process/end block structure.
- In VS Code, open SupportDiagnostics.psm1.
- Scroll to the bottom of the file (after the closing } of Get-NetworkDiagnostic) and add the following function skeleton:
function Get-SystemHealth {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$CriticalServices = @('Dhcp', 'Dnscache', 'LanmanWorkstation', 'W32Time', 'WinRM'),
[Parameter(Mandatory = $false)]
[int]$DiskThresholdPercent = 85,
[Parameter(Mandatory = $false)]
[int]$EventLogHours = 24
)
begin {
$results = [System.Collections.ArrayList]::new()
Write-Verbose "Starting system health checks..."
}
process {
}
end {
Write-Verbose "System health checks complete. $($results.Count) checks performed."
return $results
}
}
What does this code do?
This establishes the function frame with three configurable parameters:
- CriticalServices lists the Windows services to check. The defaults cover networking essentials like DHCP, DNS cache, and the time sync service.
- DiskThresholdPercent sets when disk usage triggers a failure. At 85%, the team gets alerted before the drive fills completely.
- EventLogHours controls how far back the function looks for system errors. 24 hours covers the last day of activity.
The begin block creates a results collection, and end returns it. You will fill the process block with the actual checks next.
- Save SupportDiagnostics.psm1.
Implement the three health checks
The process block will contain three distinct checks: service status, disk space, and event log errors. Each check outputs the same object shape (CheckType, Target, Result, Status, Timestamp) so all diagnostics can be processed together downstream.
- In SupportDiagnostics.psm1, find the empty process { } block inside Get-SystemHealth and replace it with the service status checks below:
process {
# Check each critical Windows service
foreach ($svc in $CriticalServices) {
try {
$service = Get-Service -Name $svc -ErrorAction Stop
$null = $results.Add([PSCustomObject]@{
CheckType = 'Service'
Target = "$($service.DisplayName) ($svc)"
Result = $service.Status.ToString()
Status = if ($service.Status -eq 'Running') { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
$null = $results.Add([PSCustomObject]@{
CheckType = 'Service'
Target = $svc
Result = "Not found: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
What does this code do?
For each service name in the $CriticalServices array, Get-Service queries its status. If the service is running, the check passes. If it is stopped or missing entirely, the check fails.
The try/catch ensures that a completely missing service (which throws a terminating error with -ErrorAction Stop) is logged as a failure instead of crashing the function.
- Directly below the service check's closing } (still inside the process block), add the disk space checks:
# Check disk space on local fixed drives
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3"
foreach ($disk in $disks) {
$usedPercent = [math]::Round((($disk.Size - $disk.FreeSpace) / $disk.Size) * 100, 1)
$null = $results.Add([PSCustomObject]@{
CheckType = 'Disk'
Target = "$($disk.DeviceID) ($([math]::Round($disk.FreeSpace / 1GB, 1)) GB free)"
Result = "$usedPercent% used"
Status = if ($usedPercent -ge $DiskThresholdPercent) { 'Fail' } else { 'Pass' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
What does this code do?
Get-CimInstance queries the Win32_LogicalDisk WMI class, filtered to local fixed drives (DriveType=3). For each drive, it calculates the percentage of space used.
If usage meets or exceeds the $DiskThresholdPercent (default 85%), the check fails. This gives the support team early warning before a drive fills completely and services start crashing.
- Below the disk checks (still inside the process block), add the event log error checks and close the process block:
# Check System event log for recent errors
$cutoff = (Get-Date).AddHours(-$EventLogHours)
try {
$errors = Get-WinEvent -FilterHashtable @{
LogName = 'System'
Level = 2
StartTime = $cutoff
} -MaxEvents 10 -ErrorAction Stop
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = "$($errors.Count) error(s) found"
Status = if ($errors.Count -gt 5) { 'Fail' } else { 'Warning' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
if ($_.Exception.Message -like '*No events were found*') {
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = '0 errors found'
Status = 'Pass'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
else {
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = "Query failed: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
}
What does this code do?
Get-WinEvent queries the System event log for Level 2 (Error) events within the configured time window. If more than 5 errors are found, the check fails. Fewer than 5 reports as a Warning.
The catch block handles a special case: when no events match the filter, Get-WinEvent throws an error rather than returning empty results. The function catches that specific message and records it as a passing check. Any other exception is recorded as a failure.
- Save SupportDiagnostics.psm1.
- Confirm the full Get-SystemHealth function sits directly below Get-NetworkDiagnostic and that the process block contains all three check sections (services, disk, event log) in order.
Seeing red squiggles or a parse error?
Check that every opening { has a matching closing }. The function has nested braces for foreach, try/catch, and if/else. A missing brace is the most common cause of parse errors in long functions.
help me find the mismatched brace in my Get-SystemHealth function
✔️ Awesome, I've got everything!
Great. Double check you have saved SupportDiagnostics.psm1 before moving on to the test.
ⓧ I'd like to double check the full code
Here is the complete SupportDiagnostics.psm1 including both functions:
function Get-NetworkDiagnostic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$Targets = @('8.8.8.8', '1.1.1.1', 'microsoft.com'),
[Parameter(Mandatory = $false)]
[int[]]$Ports = @(80, 443, 53, 3389),
[Parameter(Mandatory = $false)]
[string[]]$DnsNames = @('microsoft.com', 'google.com')
)
begin {
$results = [System.Collections.ArrayList]::new()
Write-Verbose "Starting network diagnostics..."
}
process {
foreach ($target in $Targets) {
$test = Test-NetConnection -ComputerName $target -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'Connectivity'
Target = $target
Result = if ($test.PingSucceeded) { 'Reachable' } else { 'Unreachable' }
Status = if ($test.PingSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
foreach ($port in $Ports) {
$test = Test-NetConnection -ComputerName $Targets[0] -Port $port -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'PortScan'
Target = "$($Targets[0]):$port"
Result = if ($test.TcpTestSucceeded) { 'Open' } else { 'Closed' }
Status = if ($test.TcpTestSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
foreach ($name in $DnsNames) {
try {
$dns = Resolve-DnsName -Name $name -Type A -ErrorAction Stop
$resolved = ($dns | Where-Object { $_.Type -eq 'A' } | Select-Object -First 1).IPAddress
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = $resolved
Status = 'Pass'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = "Resolution failed: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
}
end {
Write-Verbose "Network diagnostics complete. $($results.Count) checks performed."
return $results
}
}
function Get-SystemHealth {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$CriticalServices = @('Dhcp', 'Dnscache', 'LanmanWorkstation', 'W32Time', 'WinRM'),
[Parameter(Mandatory = $false)]
[int]$DiskThresholdPercent = 85,
[Parameter(Mandatory = $false)]
[int]$EventLogHours = 24
)
begin {
$results = [System.Collections.ArrayList]::new()
Write-Verbose "Starting system health checks..."
}
process {
foreach ($svc in $CriticalServices) {
try {
$service = Get-Service -Name $svc -ErrorAction Stop
$null = $results.Add([PSCustomObject]@{
CheckType = 'Service'
Target = "$($service.DisplayName) ($svc)"
Result = $service.Status.ToString()
Status = if ($service.Status -eq 'Running') { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
$null = $results.Add([PSCustomObject]@{
CheckType = 'Service'
Target = $svc
Result = "Not found: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3"
foreach ($disk in $disks) {
$usedPercent = [math]::Round((($disk.Size - $disk.FreeSpace) / $disk.Size) * 100, 1)
$null = $results.Add([PSCustomObject]@{
CheckType = 'Disk'
Target = "$($disk.DeviceID) ($([math]::Round($disk.FreeSpace / 1GB, 1)) GB free)"
Result = "$usedPercent% used"
Status = if ($usedPercent -ge $DiskThresholdPercent) { 'Fail' } else { 'Pass' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
$cutoff = (Get-Date).AddHours(-$EventLogHours)
try {
$errors = Get-WinEvent -FilterHashtable @{
LogName = 'System'
Level = 2
StartTime = $cutoff
} -MaxEvents 10 -ErrorAction Stop
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = "$($errors.Count) error(s) found"
Status = if ($errors.Count -gt 5) { 'Fail' } else { 'Warning' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
if ($_.Exception.Message -like '*No events were found*') {
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = '0 errors found'
Status = 'Pass'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
else {
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = "Query failed: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
}
end {
Write-Verbose "System health checks complete. $($results.Count) checks performed."
return $results
}
}
Test Get-SystemHealth
With the function complete, you can reimport the module and run the health checks to confirm everything works.
- In your PowerShell terminal, reimport the module and run the function by executing these commands:
Import-Module .\SupportDiagnostics.psd1 -Force
Get-SystemHealth -Verbose
What should I see?
You should see verbose messages ("Starting system health checks..." and "System health checks complete.") followed by a table of objects. Each object has CheckType, Target, Result, Status, and Timestamp columns.
You will see Service checks (one per critical service, most should show "Running" with a Pass status), Disk checks (one per local drive showing percentage used), and an EventLog check. The EventLog result depends on your machine. It may show 0 errors (Pass), a count of errors (Warning or Fail), or a query failure if permissions are restricted.
- Confirm the output includes objects with CheckType values of Service, Disk, and EventLog.
Function not recognized or returns no results?
- Make sure you saved SupportDiagnostics.psm1 before running Import-Module. The -Force flag reloads the module, but it reads whatever is on disk.
- If you see a parse error, open the file and check that the process block's opening and closing braces match. A missing } after the catch block is a common culprit.
- If you get an access denied error on the EventLog check, that is expected on some locked-down machines. The function handles it gracefully and still returns results for services and disk.
help me troubleshoot why Get-SystemHealth is not returning results after I reimport the module
Your module now has two diagnostic functions covering both network and local system health. Next up, you will build the incident classifier that takes these raw results and assigns P1 through P4 severity levels with SLA deadlines.
Build the Incident Classifier and Report Generator
Your diagnostic functions can now detect problems across the network and system. The missing piece is translating raw pass/fail results into something a support team can act on.
In this step, you will build an incident classification engine that maps each finding to a SLA-based severity level (P1 through P4) and a report generator that outputs a professional HTML document ready for ticket attachment.
In this step, get ready to:
- Write the Get-IncidentSeverity function that classifies diagnostic results by P1-P4 severity with SLA deadlines.
- Write the Export-DiagnosticReport function that generates a styled HTML report.
- Test the full pipeline from diagnostics through classification to report output.
Write the incident severity classifier
Support teams use priority levels to decide response urgency. A connectivity failure that takes the whole site offline is P1-Critical (respond within 30 minutes). A stopped service is P2-High (2 hours). A blocked port is P3-Medium (8 hours). A passing check is P4-Low (24 hours).
- In SupportDiagnostics.psm1, scroll to the bottom of the file (after the closing } of Get-SystemHealth).
- Add the Get-IncidentSeverity function by pasting the following code:
function Get-IncidentSeverity {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[PSCustomObject[]]$DiagnosticResults
)
begin {
$classified = [System.Collections.ArrayList]::new()
}
process {
foreach ($item in $DiagnosticResults) {
$severity = switch ($true) {
($item.Status -eq 'Fail' -and $item.CheckType -eq 'Connectivity') { 'P1-Critical'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'Service') { 'P2-High'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'Disk') { 'P2-High'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'DNS') { 'P2-High'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'PortScan') { 'P3-Medium'; break }
($item.Status -eq 'Warning') { 'P3-Medium'; break }
($item.Status -eq 'Pass') { 'P4-Low'; break }
default { 'P4-Low' }
}
$slaMinutes = switch ($severity) {
'P1-Critical' { 30 }
'P2-High' { 120 }
'P3-Medium' { 480 }
'P4-Low' { 1440 }
}
$null = $classified.Add([PSCustomObject]@{
CheckType = $item.CheckType
Target = $item.Target
Result = $item.Result
Status = $item.Status
Severity = $severity
SLAMinutes = $slaMinutes
SLADeadline = (Get-Date).AddMinutes($slaMinutes).ToString('yyyy-MM-dd HH:mm:ss')
Timestamp = $item.Timestamp
})
}
}
end {
return $classified
}
}
What does this code do?
This function accepts pipeline input thanks to the ValueFromPipeline = $true attribute. That means diagnostic results can be piped directly into it.
- The switch ($true) pattern evaluates each condition top-to-bottom and picks the first match. Connectivity failures get the highest severity (P1), service/disk/DNS failures get P2, port failures and warnings get P3, and passing checks get P4.
- Each severity maps to an SLA deadline in minutes. P1-Critical gives 30 minutes to respond, P2-High gives 2 hours, P3-Medium gives 8 hours, and P4-Low gives 24 hours.
- The output object includes the original diagnostic data plus the new Severity, SLAMinutes, and SLADeadline properties.
- Save SupportDiagnostics.psm1.
- You won't see a change yet. The next function (Export-DiagnosticReport) uses this classification to build the HTML report.
Seeing a red squiggly under switch?
Make sure the opening { for switch ($true) is on the same line as the keyword. PowerShell requires the brace on the same line for switch statements.
Also confirm each condition is wrapped in parentheses, for example ($item.Status -eq 'Fail' -and $item.CheckType -eq 'Connectivity').
Help me debug my Get-IncidentSeverity function in PowerShell.
Write the diagnostic report exporter
A classified list of results is useful, but a visual HTML report is what you would attach to a support ticket or share during an escalation handoff. This function builds a complete HTML document with color-coded severity indicators and summary cards.
- Below the closing } of Get-IncidentSeverity, add the Export-DiagnosticReport function by pasting the following code:
function Export-DiagnosticReport {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[PSCustomObject[]]$ClassifiedResults,
[Parameter(Mandatory = $false)]
[string]$OutputPath = ".\DiagnosticReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html",
[Parameter(Mandatory = $false)]
[string]$IncidentId = "INC$(Get-Date -Format 'yyyyMMddHHmmss')"
)
begin {
$allResults = [System.Collections.ArrayList]::new()
}
process {
foreach ($item in $ClassifiedResults) {
$null = $allResults.Add($item)
}
}
end {
$summary = @{
Total = $allResults.Count
Critical = ($allResults | Where-Object { $_.Severity -eq 'P1-Critical' }).Count
High = ($allResults | Where-Object { $_.Severity -eq 'P2-High' }).Count
Medium = ($allResults | Where-Object { $_.Severity -eq 'P3-Medium' }).Count
Low = ($allResults | Where-Object { $_.Severity -eq 'P4-Low' }).Count
Failed = ($allResults | Where-Object { $_.Status -eq 'Fail' }).Count
}
$htmlHead = @"
<!DOCTYPE html>
<html>
<head><title>Support Diagnostic Report - $IncidentId</title>
<style>
body { font-family: Segoe UI, sans-serif; margin: 20px; background: #f5f5f5; }
.header { background: #0078d4; color: white; padding: 20px; border-radius: 4px; }
.header h1 { margin: 0 0 8px 0; }
.header p { margin: 0; opacity: 0.9; }
.summary { display: flex; gap: 15px; margin: 20px 0; flex-wrap: wrap; }
.card { background: white; padding: 15px; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); flex: 1; min-width: 120px; }
.card h3 { margin: 0 0 5px 0; font-size: 14px; color: #666; }
.card .value { font-size: 24px; font-weight: bold; }
.critical { color: #d13438; }
.high { color: #ff8c00; }
.medium { color: #c19c00; }
.low { color: #107c10; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 4px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-top: 20px; }
th { background: #f0f0f0; padding: 12px; text-align: left; font-size: 13px; border-bottom: 2px solid #ddd; }
td { padding: 10px 12px; border-top: 1px solid #eee; font-size: 13px; }
tr:hover { background: #f9f9f9; }
.status-fail { background: #fde7e9; }
.status-warning { background: #fff4ce; }
.footer { margin-top: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>IT Support Diagnostic Report</h1>
<p>Incident ID: $IncidentId | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Host: $env:COMPUTERNAME</p>
</div>
<div class="summary">
<div class="card"><h3>Total Checks</h3><div class="value">$($summary.Total)</div></div>
<div class="card"><h3>Critical (P1)</h3><div class="value critical">$($summary.Critical)</div></div>
<div class="card"><h3>High (P2)</h3><div class="value high">$($summary.High)</div></div>
<div class="card"><h3>Medium (P3)</h3><div class="value medium">$($summary.Medium)</div></div>
<div class="card"><h3>Passed</h3><div class="value low">$($summary.Low)</div></div>
</div>
<h2>Diagnostic Results</h2>
<table>
<tr><th>Severity</th><th>Type</th><th>Target</th><th>Result</th><th>Status</th><th>SLA Deadline</th></tr>
"@
$htmlRows = ""
$sortedResults = $allResults | Sort-Object @{Expression = {
switch ($_.Severity) {
'P1-Critical' { 1 }
'P2-High' { 2 }
'P3-Medium' { 3 }
'P4-Low' { 4 }
}
}}
foreach ($item in $sortedResults) {
$rowClass = switch ($item.Status) {
'Fail' { 'status-fail' }
'Warning' { 'status-warning' }
default { '' }
}
$htmlRows += "<tr class='$rowClass'><td>$($item.Severity)</td><td>$($item.CheckType)</td><td>$($item.Target)</td><td>$($item.Result)</td><td>$($item.Status)</td><td>$($item.SLADeadline)</td></tr>`n"
}
$htmlFoot = @"
</table>
<div class="footer">
<p>Report generated by SupportDiagnostics Module v1.0.0 | PowerShell $($PSVersionTable.PSVersion)</p>
</div>
</body>
</html>
"@
$fullHtml = $htmlHead + $htmlRows + $htmlFoot
$fullHtml | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Verbose "Report exported to: $OutputPath"
return [PSCustomObject]@{
IncidentId = $IncidentId
ReportPath = (Resolve-Path $OutputPath).Path
Summary = $summary
Generated = Get-Date
}
}
}
What does this code do?
This function collects classified results via the pipeline, then builds a full HTML page in three parts: head, rows, and footer.
- The $summary hashtable counts results by severity so the report can display summary cards at the top.
- The $htmlHead here-string contains the full CSS and HTML structure with Segoe UI font, color-coded severity classes, and responsive summary cards.
- Results are sorted by severity (P1 first, P4 last) and each row gets a CSS class for visual highlighting. Failed rows appear with a red background, warnings with yellow.
- The function writes the combined HTML using Out-File -Encoding UTF8 and returns a summary object with the incident ID, report file path, and severity counts.
- Save SupportDiagnostics.psm1.
- You won't see a change yet. We will test the full pipeline in the next substep to confirm the report generates correctly.
Seeing a parsing error on the here-string?
PowerShell here-strings are strict about formatting. The opening @" must have nothing else on that line (not even a space after the quote). The closing "@ must be at the very start of its line with no leading whitespace.
If VS Code auto-indents the closing "@, manually remove any spaces or tabs before it.
Help me fix a here-string parsing error in my PowerShell function.
✔️ Awesome, I've got everything!
Great. Double check you have saved SupportDiagnostics.psm1 with both new functions before moving on.
ⓧ I'd like to double check the full code
function Get-NetworkDiagnostic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$Targets = @('8.8.8.8', '1.1.1.1', 'microsoft.com'),
[Parameter(Mandatory = $false)]
[int[]]$Ports = @(80, 443, 53, 3389),
[Parameter(Mandatory = $false)]
[string[]]$DnsNames = @('microsoft.com', 'google.com')
)
begin {
$results = [System.Collections.ArrayList]::new()
Write-Verbose "Starting network diagnostics..."
}
process {
foreach ($target in $Targets) {
$test = Test-NetConnection -ComputerName $target -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'Connectivity'
Target = $target
Result = if ($test.PingSucceeded) { 'Reachable' } else { 'Unreachable' }
Status = if ($test.PingSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
foreach ($port in $Ports) {
$test = Test-NetConnection -ComputerName $Targets[0] -Port $port -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'PortScan'
Target = "$($Targets[0]):$port"
Result = if ($test.TcpTestSucceeded) { 'Open' } else { 'Closed' }
Status = if ($test.TcpTestSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
foreach ($name in $DnsNames) {
try {
$dns = Resolve-DnsName -Name $name -Type A -ErrorAction Stop
$resolved = ($dns | Where-Object { $_.Type -eq 'A' } | Select-Object -First 1).IPAddress
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = $resolved
Status = 'Pass'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = "Resolution failed: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
}
end {
Write-Verbose "Network diagnostics complete. $($results.Count) checks performed."
return $results
}
}
function Get-SystemHealth {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$CriticalServices = @('Dhcp', 'Dnscache', 'LanmanWorkstation', 'W32Time', 'WinRM'),
[Parameter(Mandatory = $false)]
[int]$DiskThresholdPercent = 85,
[Parameter(Mandatory = $false)]
[int]$EventLogHours = 24
)
begin {
$results = [System.Collections.ArrayList]::new()
Write-Verbose "Starting system health checks..."
}
process {
foreach ($svc in $CriticalServices) {
try {
$service = Get-Service -Name $svc -ErrorAction Stop
$null = $results.Add([PSCustomObject]@{
CheckType = 'Service'
Target = "$($service.DisplayName) ($svc)"
Result = $service.Status.ToString()
Status = if ($service.Status -eq 'Running') { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
$null = $results.Add([PSCustomObject]@{
CheckType = 'Service'
Target = $svc
Result = "Not found: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3"
foreach ($disk in $disks) {
$usedPercent = [math]::Round((($disk.Size - $disk.FreeSpace) / $disk.Size) * 100, 1)
$null = $results.Add([PSCustomObject]@{
CheckType = 'Disk'
Target = "$($disk.DeviceID) ($([math]::Round($disk.FreeSpace / 1GB, 1)) GB free)"
Result = "$usedPercent% used"
Status = if ($usedPercent -ge $DiskThresholdPercent) { 'Fail' } else { 'Pass' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
$cutoff = (Get-Date).AddHours(-$EventLogHours)
try {
$errors = Get-WinEvent -FilterHashtable @{
LogName = 'System'
Level = 2
StartTime = $cutoff
} -MaxEvents 10 -ErrorAction Stop
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = "$($errors.Count) error(s) found"
Status = if ($errors.Count -gt 5) { 'Fail' } else { 'Warning' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
if ($_.Exception.Message -like '*No events were found*') {
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = '0 errors found'
Status = 'Pass'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
else {
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = "Query failed: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
}
end {
Write-Verbose "System health checks complete. $($results.Count) checks performed."
return $results
}
}
function Get-IncidentSeverity {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[PSCustomObject[]]$DiagnosticResults
)
begin {
$classified = [System.Collections.ArrayList]::new()
}
process {
foreach ($item in $DiagnosticResults) {
$severity = switch ($true) {
($item.Status -eq 'Fail' -and $item.CheckType -eq 'Connectivity') { 'P1-Critical'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'Service') { 'P2-High'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'Disk') { 'P2-High'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'DNS') { 'P2-High'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'PortScan') { 'P3-Medium'; break }
($item.Status -eq 'Warning') { 'P3-Medium'; break }
($item.Status -eq 'Pass') { 'P4-Low'; break }
default { 'P4-Low' }
}
$slaMinutes = switch ($severity) {
'P1-Critical' { 30 }
'P2-High' { 120 }
'P3-Medium' { 480 }
'P4-Low' { 1440 }
}
$null = $classified.Add([PSCustomObject]@{
CheckType = $item.CheckType
Target = $item.Target
Result = $item.Result
Status = $item.Status
Severity = $severity
SLAMinutes = $slaMinutes
SLADeadline = (Get-Date).AddMinutes($slaMinutes).ToString('yyyy-MM-dd HH:mm:ss')
Timestamp = $item.Timestamp
})
}
}
end {
return $classified
}
}
function Export-DiagnosticReport {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[PSCustomObject[]]$ClassifiedResults,
[Parameter(Mandatory = $false)]
[string]$OutputPath = ".\DiagnosticReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html",
[Parameter(Mandatory = $false)]
[string]$IncidentId = "INC$(Get-Date -Format 'yyyyMMddHHmmss')"
)
begin {
$allResults = [System.Collections.ArrayList]::new()
}
process {
foreach ($item in $ClassifiedResults) {
$null = $allResults.Add($item)
}
}
end {
$summary = @{
Total = $allResults.Count
Critical = ($allResults | Where-Object { $_.Severity -eq 'P1-Critical' }).Count
High = ($allResults | Where-Object { $_.Severity -eq 'P2-High' }).Count
Medium = ($allResults | Where-Object { $_.Severity -eq 'P3-Medium' }).Count
Low = ($allResults | Where-Object { $_.Severity -eq 'P4-Low' }).Count
Failed = ($allResults | Where-Object { $_.Status -eq 'Fail' }).Count
}
$htmlHead = @"
<!DOCTYPE html>
<html>
<head><title>Support Diagnostic Report - $IncidentId</title>
<style>
body { font-family: Segoe UI, sans-serif; margin: 20px; background: #f5f5f5; }
.header { background: #0078d4; color: white; padding: 20px; border-radius: 4px; }
.header h1 { margin: 0 0 8px 0; }
.header p { margin: 0; opacity: 0.9; }
.summary { display: flex; gap: 15px; margin: 20px 0; flex-wrap: wrap; }
.card { background: white; padding: 15px; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); flex: 1; min-width: 120px; }
.card h3 { margin: 0 0 5px 0; font-size: 14px; color: #666; }
.card .value { font-size: 24px; font-weight: bold; }
.critical { color: #d13438; }
.high { color: #ff8c00; }
.medium { color: #c19c00; }
.low { color: #107c10; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 4px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-top: 20px; }
th { background: #f0f0f0; padding: 12px; text-align: left; font-size: 13px; border-bottom: 2px solid #ddd; }
td { padding: 10px 12px; border-top: 1px solid #eee; font-size: 13px; }
tr:hover { background: #f9f9f9; }
.status-fail { background: #fde7e9; }
.status-warning { background: #fff4ce; }
.footer { margin-top: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>IT Support Diagnostic Report</h1>
<p>Incident ID: $IncidentId | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Host: $env:COMPUTERNAME</p>
</div>
<div class="summary">
<div class="card"><h3>Total Checks</h3><div class="value">$($summary.Total)</div></div>
<div class="card"><h3>Critical (P1)</h3><div class="value critical">$($summary.Critical)</div></div>
<div class="card"><h3>High (P2)</h3><div class="value high">$($summary.High)</div></div>
<div class="card"><h3>Medium (P3)</h3><div class="value medium">$($summary.Medium)</div></div>
<div class="card"><h3>Passed</h3><div class="value low">$($summary.Low)</div></div>
</div>
<h2>Diagnostic Results</h2>
<table>
<tr><th>Severity</th><th>Type</th><th>Target</th><th>Result</th><th>Status</th><th>SLA Deadline</th></tr>
"@
$htmlRows = ""
$sortedResults = $allResults | Sort-Object @{Expression = {
switch ($_.Severity) {
'P1-Critical' { 1 }
'P2-High' { 2 }
'P3-Medium' { 3 }
'P4-Low' { 4 }
}
}}
foreach ($item in $sortedResults) {
$rowClass = switch ($item.Status) {
'Fail' { 'status-fail' }
'Warning' { 'status-warning' }
default { '' }
}
$htmlRows += "<tr class='$rowClass'><td>$($item.Severity)</td><td>$($item.CheckType)</td><td>$($item.Target)</td><td>$($item.Result)</td><td>$($item.Status)</td><td>$($item.SLADeadline)</td></tr>`n"
}
$htmlFoot = @"
</table>
<div class="footer">
<p>Report generated by SupportDiagnostics Module v1.0.0 | PowerShell $($PSVersionTable.PSVersion)</p>
</div>
</body>
</html>
"@
$fullHtml = $htmlHead + $htmlRows + $htmlFoot
$fullHtml | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Verbose "Report exported to: $OutputPath"
return [PSCustomObject]@{
IncidentId = $IncidentId
ReportPath = (Resolve-Path $OutputPath).Path
Summary = $summary
Generated = Get-Date
}
}
}
Test the full diagnostic pipeline
Now you can chain all three functions together using the PowerShell pipeline. The output of one function flows directly into the next.
- Re-import the module to pick up the new functions by running this command in your terminal:
Import-Module .\SupportDiagnostics.psd1 -Force
- Run the full pipeline to generate a classified HTML report:
Get-NetworkDiagnostic | Get-IncidentSeverity | Export-DiagnosticReport
- You should see a summary object returned in the terminal with an IncidentId, ReportPath, Summary, and Generated property.
- Open the HTML file shown in ReportPath in your browser. You should see a blue header with the incident ID, five summary cards showing P1 through P4 counts, and a results table with color-coded rows sorted by severity.
What should I see in the report?
The summary cards at the top show how many checks fell into each severity bucket. Most checks on a healthy system will be P4-Low (green). If any connectivity checks failed, those appear as P1-Critical (red).
The table below lists every diagnostic result sorted from most severe to least severe. Failed rows have a pink background. Warning rows have a yellow background. Each row includes an SLA Deadline timestamp showing when the issue must be resolved.
Getting an error about Resolve-Path?
If you see an error mentioning that the path does not exist, make sure your terminal's working directory is the SupportDiagnostics folder. The default OutputPath uses a relative path (.\DiagnosticReport_...), so PowerShell writes the file to whatever folder your terminal is currently in.
Help me troubleshoot my Export-DiagnosticReport pipeline output.
Your module now takes raw diagnostic data all the way through to a ticket-ready report. Next up, you will build a single orchestrator command that runs the entire workflow with one call.
Build the Orchestrator Function
Your module now has four specialized functions that each handle one piece of the diagnostic puzzle. In a real support scenario, you would not want to run each function manually and wire them together every time a ticket arrives.
In this step, you will build the orchestrator function that ties everything together. A single Invoke-SupportDiagnostic command will run all diagnostics, classify findings, generate the HTML report, and return a summary object.
In this step, get ready to:
- Write the Invoke-SupportDiagnostic orchestrator function with parameters and pipeline logic.
- Test the complete diagnostic workflow with a single command that produces an HTML report and summary object.
Write the orchestrator function
The orchestrator pattern is common in support automation. One top-level function coordinates all sub-tasks so the engineer only needs to remember a single command.
- In VS Code, open SupportDiagnostics.psm1.
- Scroll to the bottom of the file, after the closing } of the Export-DiagnosticReport function.
- Add the following function:
function Invoke-SupportDiagnostic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$Targets = @('8.8.8.8', '1.1.1.1', 'microsoft.com'),
[Parameter(Mandatory = $false)]
[string[]]$CriticalServices = @('Dhcp', 'Dnscache', 'LanmanWorkstation', 'W32Time', 'WinRM'),
[Parameter(Mandatory = $false)]
[string]$OutputPath = ".\DiagnosticReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html",
[Parameter(Mandatory = $false)]
[switch]$OpenReport
)
Write-Verbose "=== IT Support Diagnostic Toolkit ==="
Write-Verbose "Starting full diagnostic suite..."
# Run network diagnostics against all targets
$networkResults = Get-NetworkDiagnostic -Targets $Targets -Verbose:$VerbosePreference
# Run system health checks against critical services
$systemResults = Get-SystemHealth -CriticalServices $CriticalServices -Verbose:$VerbosePreference
# Combine both result sets into a single array
$allResults = @($networkResults) + @($systemResults)
# Classify each finding by incident severity
$classified = $allResults | Get-IncidentSeverity
# Generate the HTML report with classified results
$report = Export-DiagnosticReport -ClassifiedResults $classified -OutputPath $OutputPath -Verbose:$VerbosePreference
# Open the report in the default browser if requested
if ($OpenReport) {
Start-Process $report.ReportPath
}
Write-Verbose "=== Diagnostic Complete ==="
return $report
}
What does this function do?
This is the single entry point for the entire module. It coordinates four other functions into one workflow.
- $Targets and $CriticalServices let you customize which hosts and services to check without changing code.
- $OutputPath defaults to a timestamped filename so each run produces a unique report.
- -Verbose:$VerbosePreference passes the verbose flag down to child functions so you see all progress messages from a single -Verbose switch at the top.
- The [switch]$OpenReport parameter uses Start-Process to launch the HTML file in your default browser.
- The function returns the same summary PSCustomObject that Export-DiagnosticReport produces, containing IncidentId, ReportPath, Summary counts, and Generated timestamp.
Why an orchestrator instead of a script?
A standalone script requires the engineer to remember file paths and execution order. An orchestrator function lives inside the module and is discoverable through Get-Command. It also supports tab-completion on parameters, built-in help, and composability with other PowerShell commands.
- Save SupportDiagnostics.psm1.
Seeing a red squiggle under the function name?
Make sure the new function is placed after the closing brace of Export-DiagnosticReport, not inside it. Each function must be at the top level of the file.
Check that every opening { in the new function has a matching closing }.
Help me debug a syntax error in my PowerShell orchestrator function.
Test the full diagnostic flow
The module needs to be reimported so PowerShell picks up the new function. Then you can run the full diagnostic with a single command.
- Reimport the module and run the orchestrator by running these commands in your PowerShell terminal:
Import-Module .\SupportDiagnostics.psd1 -Force
Invoke-SupportDiagnostic -Verbose -OpenReport
What does this command do?
- Import-Module -Force reloads the module so the new function becomes available without restarting your session.
- -Verbose shows progress messages from every child function as they execute.
- -OpenReport opens the generated HTML file in your default browser after diagnostics complete.
- Wait for the command to finish. Network checks take a few seconds per target.
- You should see verbose output in the terminal showing each diagnostic phase, followed by a summary object with properties IncidentId, ReportPath, Summary, and Generated.
- Your default browser should open with the full HTML diagnostic report showing severity-classified results and SLA deadlines.
Report not opening in the browser?
If the terminal shows the summary object but no browser opens, check that your default browser is set in Windows. You can also open the report manually by copying the ReportPath value from the output and pasting it into your browser's address bar.
If you see an error about Invoke-SupportDiagnostic not being recognized, confirm you ran the Import-Module line with -Force first.
Help me troubleshoot my Invoke-SupportDiagnostic command.
✔️ Awesome, I've got everything!
Great. Double check you have saved SupportDiagnostics.psm1 with all 5 functions.
ⓧ I'd like to double check the full code
Here is the complete SupportDiagnostics.psm1 file with all 5 functions:
function Get-NetworkDiagnostic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$Targets = @('8.8.8.8', '1.1.1.1', 'microsoft.com'),
[Parameter(Mandatory = $false)]
[int[]]$Ports = @(80, 443, 53, 3389),
[Parameter(Mandatory = $false)]
[string[]]$DnsNames = @('microsoft.com', 'google.com')
)
begin {
$results = [System.Collections.ArrayList]::new()
Write-Verbose "Starting network diagnostics..."
}
process {
foreach ($target in $Targets) {
$test = Test-NetConnection -ComputerName $target -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'Connectivity'
Target = $target
Result = if ($test.PingSucceeded) { 'Reachable' } else { 'Unreachable' }
Status = if ($test.PingSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
foreach ($port in $Ports) {
$test = Test-NetConnection -ComputerName $Targets[0] -Port $port -WarningAction SilentlyContinue
$null = $results.Add([PSCustomObject]@{
CheckType = 'PortScan'
Target = "$($Targets[0]):$port"
Result = if ($test.TcpTestSucceeded) { 'Open' } else { 'Closed' }
Status = if ($test.TcpTestSucceeded) { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
foreach ($name in $DnsNames) {
try {
$dns = Resolve-DnsName -Name $name -Type A -ErrorAction Stop
$resolved = ($dns | Where-Object { $_.Type -eq 'A' } | Select-Object -First 1).IPAddress
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = $resolved
Status = 'Pass'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
$null = $results.Add([PSCustomObject]@{
CheckType = 'DNS'
Target = $name
Result = "Resolution failed: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
}
end {
Write-Verbose "Network diagnostics complete. $($results.Count) checks performed."
return $results
}
}
function Get-SystemHealth {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$CriticalServices = @('Dhcp', 'Dnscache', 'LanmanWorkstation', 'W32Time', 'WinRM'),
[Parameter(Mandatory = $false)]
[int]$DiskThresholdPercent = 85,
[Parameter(Mandatory = $false)]
[int]$EventLogHours = 24
)
begin {
$results = [System.Collections.ArrayList]::new()
Write-Verbose "Starting system health checks..."
}
process {
foreach ($svc in $CriticalServices) {
try {
$service = Get-Service -Name $svc -ErrorAction Stop
$null = $results.Add([PSCustomObject]@{
CheckType = 'Service'
Target = "$($service.DisplayName) ($svc)"
Result = $service.Status.ToString()
Status = if ($service.Status -eq 'Running') { 'Pass' } else { 'Fail' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
$null = $results.Add([PSCustomObject]@{
CheckType = 'Service'
Target = $svc
Result = "Not found: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3"
foreach ($disk in $disks) {
$usedPercent = [math]::Round((($disk.Size - $disk.FreeSpace) / $disk.Size) * 100, 1)
$null = $results.Add([PSCustomObject]@{
CheckType = 'Disk'
Target = "$($disk.DeviceID) ($([math]::Round($disk.FreeSpace / 1GB, 1)) GB free)"
Result = "$usedPercent% used"
Status = if ($usedPercent -ge $DiskThresholdPercent) { 'Fail' } else { 'Pass' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
$cutoff = (Get-Date).AddHours(-$EventLogHours)
try {
$errors = Get-WinEvent -FilterHashtable @{
LogName = 'System'
Level = 2
StartTime = $cutoff
} -MaxEvents 10 -ErrorAction Stop
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = "$($errors.Count) error(s) found"
Status = if ($errors.Count -gt 5) { 'Fail' } else { 'Warning' }
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
catch {
if ($_.Exception.Message -like '*No events were found*') {
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = '0 errors found'
Status = 'Pass'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
else {
$null = $results.Add([PSCustomObject]@{
CheckType = 'EventLog'
Target = "System errors (last $EventLogHours hrs)"
Result = "Query failed: $($_.Exception.Message)"
Status = 'Fail'
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
})
}
}
}
end {
Write-Verbose "System health checks complete. $($results.Count) checks performed."
return $results
}
}
function Get-IncidentSeverity {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[PSCustomObject[]]$DiagnosticResults
)
begin {
$classified = [System.Collections.ArrayList]::new()
}
process {
foreach ($item in $DiagnosticResults) {
$severity = switch ($true) {
($item.Status -eq 'Fail' -and $item.CheckType -eq 'Connectivity') { 'P1-Critical'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'Service') { 'P2-High'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'Disk') { 'P2-High'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'DNS') { 'P2-High'; break }
($item.Status -eq 'Fail' -and $item.CheckType -eq 'PortScan') { 'P3-Medium'; break }
($item.Status -eq 'Warning') { 'P3-Medium'; break }
($item.Status -eq 'Pass') { 'P4-Low'; break }
default { 'P4-Low' }
}
$slaMinutes = switch ($severity) {
'P1-Critical' { 30 }
'P2-High' { 120 }
'P3-Medium' { 480 }
'P4-Low' { 1440 }
}
$null = $classified.Add([PSCustomObject]@{
CheckType = $item.CheckType
Target = $item.Target
Result = $item.Result
Status = $item.Status
Severity = $severity
SLAMinutes = $slaMinutes
SLADeadline = (Get-Date).AddMinutes($slaMinutes).ToString('yyyy-MM-dd HH:mm:ss')
Timestamp = $item.Timestamp
})
}
}
end {
return $classified
}
}
function Export-DiagnosticReport {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[PSCustomObject[]]$ClassifiedResults,
[Parameter(Mandatory = $false)]
[string]$OutputPath = ".\DiagnosticReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html",
[Parameter(Mandatory = $false)]
[string]$IncidentId = "INC$(Get-Date -Format 'yyyyMMddHHmmss')"
)
begin {
$allResults = [System.Collections.ArrayList]::new()
}
process {
foreach ($item in $ClassifiedResults) {
$null = $allResults.Add($item)
}
}
end {
$summary = @{
Total = $allResults.Count
Critical = ($allResults | Where-Object { $_.Severity -eq 'P1-Critical' }).Count
High = ($allResults | Where-Object { $_.Severity -eq 'P2-High' }).Count
Medium = ($allResults | Where-Object { $_.Severity -eq 'P3-Medium' }).Count
Low = ($allResults | Where-Object { $_.Severity -eq 'P4-Low' }).Count
Failed = ($allResults | Where-Object { $_.Status -eq 'Fail' }).Count
}
$htmlHead = @"
<!DOCTYPE html>
<html>
<head><title>Support Diagnostic Report - $IncidentId</title>
<style>
body { font-family: Segoe UI, sans-serif; margin: 20px; background: #f5f5f5; }
.header { background: #0078d4; color: white; padding: 20px; border-radius: 4px; }
.header h1 { margin: 0 0 8px 0; }
.header p { margin: 0; opacity: 0.9; }
.summary { display: flex; gap: 15px; margin: 20px 0; flex-wrap: wrap; }
.card { background: white; padding: 15px; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); flex: 1; min-width: 120px; }
.card h3 { margin: 0 0 5px 0; font-size: 14px; color: #666; }
.card .value { font-size: 24px; font-weight: bold; }
.critical { color: #d13438; }
.high { color: #ff8c00; }
.medium { color: #c19c00; }
.low { color: #107c10; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 4px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-top: 20px; }
th { background: #f0f0f0; padding: 12px; text-align: left; font-size: 13px; border-bottom: 2px solid #ddd; }
td { padding: 10px 12px; border-top: 1px solid #eee; font-size: 13px; }
tr:hover { background: #f9f9f9; }
.status-fail { background: #fde7e9; }
.status-warning { background: #fff4ce; }
.footer { margin-top: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>IT Support Diagnostic Report</h1>
<p>Incident ID: $IncidentId | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Host: $env:COMPUTERNAME</p>
</div>
<div class="summary">
<div class="card"><h3>Total Checks</h3><div class="value">$($summary.Total)</div></div>
<div class="card"><h3>Critical (P1)</h3><div class="value critical">$($summary.Critical)</div></div>
<div class="card"><h3>High (P2)</h3><div class="value high">$($summary.High)</div></div>
<div class="card"><h3>Medium (P3)</h3><div class="value medium">$($summary.Medium)</div></div>
<div class="card"><h3>Passed</h3><div class="value low">$($summary.Low)</div></div>
</div>
<h2>Diagnostic Results</h2>
<table>
<tr><th>Severity</th><th>Type</th><th>Target</th><th>Result</th><th>Status</th><th>SLA Deadline</th></tr>
"@
$htmlRows = ""
$sortedResults = $allResults | Sort-Object @{Expression = {
switch ($_.Severity) {
'P1-Critical' { 1 }
'P2-High' { 2 }
'P3-Medium' { 3 }
'P4-Low' { 4 }
}
}}
foreach ($item in $sortedResults) {
$rowClass = switch ($item.Status) {
'Fail' { 'status-fail' }
'Warning' { 'status-warning' }
default { '' }
}
$htmlRows += "<tr class='$rowClass'><td>$($item.Severity)</td><td>$($item.CheckType)</td><td>$($item.Target)</td><td>$($item.Result)</td><td>$($item.Status)</td><td>$($item.SLADeadline)</td></tr>`n"
}
$htmlFoot = @"
</table>
<div class="footer">
<p>Report generated by SupportDiagnostics Module v1.0.0 | PowerShell $($PSVersionTable.PSVersion)</p>
</div>
</body>
</html>
"@
$fullHtml = $htmlHead + $htmlRows + $htmlFoot
$fullHtml | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Verbose "Report exported to: $OutputPath"
return [PSCustomObject]@{
IncidentId = $IncidentId
ReportPath = (Resolve-Path $OutputPath).Path
Summary = $summary
Generated = Get-Date
}
}
}
function Invoke-SupportDiagnostic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string[]]$Targets = @('8.8.8.8', '1.1.1.1', 'microsoft.com'),
[Parameter(Mandatory = $false)]
[string[]]$CriticalServices = @('Dhcp', 'Dnscache', 'LanmanWorkstation', 'W32Time', 'WinRM'),
[Parameter(Mandatory = $false)]
[string]$OutputPath = ".\DiagnosticReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html",
[Parameter(Mandatory = $false)]
[switch]$OpenReport
)
Write-Verbose "=== IT Support Diagnostic Toolkit ==="
Write-Verbose "Starting full diagnostic suite..."
$networkResults = Get-NetworkDiagnostic -Targets $Targets -Verbose:$VerbosePreference
$systemResults = Get-SystemHealth -CriticalServices $CriticalServices -Verbose:$VerbosePreference
$allResults = @($networkResults) + @($systemResults)
$classified = $allResults | Get-IncidentSeverity
$report = Export-DiagnosticReport -ClassifiedResults $classified -OutputPath $OutputPath -Verbose:$VerbosePreference
if ($OpenReport) {
Start-Process $report.ReportPath
}
Write-Verbose "=== Diagnostic Complete ==="
return $report
}
Your module is complete. A single command now runs the full diagnostic suite, classifies every finding by incident severity, generates a professional HTML report, and returns structured data for further automation. Next up, the Secret Mission adds automated remediation capabilities with safe confirmation prompts.
Secret mission
Add Automated Remediation with ShouldProcess
Your diagnostic module identifies problems, but what if it could also fix them? In this secret mission, you'll add an automated remediation layer that can restart failed services and clear DNS cache, all protected by PowerShell's ShouldProcess confirmation pattern so no changes happen without explicit approval.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The SupportDiagnostics folder containing your module files (SupportDiagnostics.psd1 and SupportDiagnostics.psm1).
- Generated HTML diagnostic report files (DiagnosticReport_*.html).
- The SupportDiagnostics module loaded in your PowerShell session.
Keep everything running
No action needed. Choose this if you plan to keep using the module for real diagnostics or want to extend it further.
- Your SupportDiagnostics folder stays where you created it.
- You can import the module in any future PowerShell session by running Import-Module with the path to your .psd1 file.
- Generated HTML reports remain in the directory where you ran Invoke-SupportDiagnostic.
Pause - I'll come back to this later
Unload the module from your current PowerShell session to free up memory. Your files stay on disk for next time.
- Remove the module from your active session by running:
Remove-Module SupportDiagnostics
- Your SupportDiagnostics folder and all generated reports stay on disk.
- When you return, re-import the module with Import-Module and pick up where you left off.
Delete - I don't want to use this again
Remove all project files and unload the module completely.
- Unload the module from your session by running:
Remove-Module SupportDiagnostics -ErrorAction SilentlyContinue
- Delete the entire SupportDiagnostics folder by running:
Remove-Item -Path .\SupportDiagnostics -Recurse -Force
- Delete any generated HTML reports in your current directory by running:
Remove-Item -Path .\DiagnosticReport_*.html -Force
- Confirm the files are gone by running:
Get-ChildItem .\SupportDiagnostics -ErrorAction SilentlyContinue
If nothing is returned, the folder has been successfully removed.
Nice Work!
Nice Work!
You just built a production-grade PowerShell module that automates IT support diagnostics with a single command. A full triage workflow that once took manual effort now runs in seconds and produces a ticket-ready report.
You've learned how to:
- Built a reusable PowerShell module with a manifest and 6 exported advanced functions covering network connectivity, DNS resolution, TCP port scanning, service health, disk usage, and event log analysis.
- Built an incident classification engine that maps diagnostic findings to P1-P4 severity levels with calculated SLA deadlines.
- Generated professional HTML reports with severity color-coding, summary cards, and ticket-ready formatting using pipeline-composed functions.
- Secret Mission: Added ShouldProcess-based automated remediation with full -WhatIf/-Confirm audit support for safe, auditable system changes.
Ready to quiz yourself?