This article is a follow on from the article here
The Previous article covers reporting from the tool Dcdiag - but fails to cover any other performance metrics that could also be critical to the health of your domain controller, this post covers that side of the equation.
If you are looking for more of a "System Information" report, which is primality designed for Domain Controllers, then this report is currently a separate HTML report but could be easily added to the main report, this covers uptime, patches, service status, disk utilisation and replication status - all with the relevant status dots system.
This will report on the general health of your Domain Controllers with the information in the image above, first you need to get a data collection task to get the data from the DC's then you need to produce the HTML.
Collect the Data
First we need to collect all the data for this report this is done without using WinRM as this can cause connection issues when going though certain firewalls if certain ports are not open, this will collection information that includes:
- Last Boot time
- Current Uptime
- Recent Patches with date installed
- Server service status (linked to ADDS)
- Disk Status
- ADDS Replication Status
When you run the script it will save that data to a subfolder from where the script is run called SystemHealth as below:
This folder will be used later on for the HTML generation for the visual reporting.
Script : ADDSSysInfo.ps1
[CmdletBinding()]
param(
[Parameter(Mandatory=$false)]
[System.Management.Automation.PSCredential]
[System.Management.Automation.Credential()]
$Credential = [System.Management.Automation.PSCredential]::Empty
)
# Enable verbose output
$VerbosePreference = "Continue"
# Check for Administrator privileges
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
$isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Error "This script requires Administrator privileges. Please run as Administrator."
exit 1
}
# Get all Domain Controllers from Active Directory
Write-Verbose "Discovering Domain Controllers in the forest..."
try {
$DomainControllers = (Get-ADForest).Domains | ForEach-Object {
Get-ADDomainController -Filter * -Server $_ | Select-Object -ExpandProperty HostName
}
Write-Verbose "Found $($DomainControllers.Count) Domain Controllers:"
$DomainControllers | ForEach-Object { Write-Verbose " - $_" }
} catch {
Write-Error "Failed to discover Domain Controllers: $($_.Exception.Message)"
exit 1
}
# Get the directory where the script is being executed from
$scriptPath = $PSScriptRoot
if (!$scriptPath) {
$scriptPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
}
# Create output directory structure relative to script location - simplified
$systemHealthPath = Join-Path $scriptPath "SystemHealth"
# Create directory if it doesn't exist
if (!(Test-Path -Path $systemHealthPath)) {
Write-Verbose "Creating directory: $systemHealthPath"
New-Item -ItemType Directory -Path $systemHealthPath -Force | Out-Null
}
# Later in the script, the $outputFile path creation becomes:
$outputFile = Join-Path $systemHealthPath "DC_Health_Status_${dc}_$timestamp.txt"
function Test-WMIConnection {
param(
[string]$ComputerName,
[System.Management.Automation.PSCredential]$Credential
)
try {
if ($Credential) {
$wmiTest = Get-WmiObject -Class Win32_ComputerSystem -ComputerName $ComputerName -Credential $Credential -ErrorAction Stop
} else {
$wmiTest = Get-WmiObject -Class Win32_ComputerSystem -ComputerName $ComputerName -ErrorAction Stop
}
return $true
} catch {
Write-Verbose "WMI connection test failed for $ComputerName : $($_.Exception.Message)"
return $false
}
}
function Get-RemoteData {
param(
[string]$ComputerName,
[string]$WMIClass,
[System.Management.Automation.PSCredential]$Credential,
[string]$Filter
)
try {
$wmiParams = @{
ComputerName = $ComputerName
Class = $WMIClass
ErrorAction = 'Stop'
}
if ($Credential) {
$wmiParams['Credential'] = $Credential
}
if ($Filter) {
$wmiParams['Filter'] = $Filter
}
return Get-WmiObject @wmiParams
} catch {
Write-Verbose "Failed to get $WMIClass from $ComputerName : $($_.Exception.Message)"
return $null
}
}
function Get-DCHealth {
[CmdletBinding()]
param (
[string]$DomainController,
[System.Management.Automation.PSCredential]$Credential
)
Write-Verbose "Starting health check for $DomainController..."
$report = @()
$report += "Domain Controller Health Report for $DomainController - Generated on $(Get-Date)`n"
$report += "=" * 80 + "`n"
# Test WMI connectivity first
if (-not (Test-WMIConnection -ComputerName $DomainController -Credential $Credential)) {
$report += "ERROR: Cannot establish WMI connection to $DomainController"
return $report
}
try {
# Get OS Information
Write-Verbose "Getting operating system information..."
$osInfo = Get-RemoteData -ComputerName $DomainController -WMIClass "Win32_OperatingSystem" -Credential $Credential
if ($osInfo) {
$lastBoot = $osInfo.ConvertToDateTime($osInfo.LastBootUpTime)
$uptime = (Get-Date) - $lastBoot
$report += "`nLast Reboot Time:"
$report += "---------------"
$report += "Last Boot: $lastBoot"
$report += "Current Uptime: $($uptime.Days) days, $($uptime.Hours) hours, $($uptime.Minutes) minutes`n"
} else {
$report += "Unable to get system boot time information"
}
# Get patches
Write-Verbose "Getting patch information..."
$patches = Get-RemoteData -ComputerName $DomainController -WMIClass "Win32_QuickFixEngineering" -Credential $Credential |
Sort-Object -Property InstalledOn -Descending | Select-Object -First 5
$report += "`nLast Installed Patches:"
$report += "--------------------"
if ($patches) {
foreach ($patch in $patches) {
$report += "KB{0} - Installed: {1}" -f $patch.HotFixID, $patch.InstalledOn
}
# Calculate patch-to-reboot difference if we have both pieces of information
if ($lastBoot -and $patches[0].InstalledOn) {
$lastPatchDate = $patches[0].InstalledOn
$daysSinceReboot = ($lastPatchDate - $lastBoot).Days
$report += "`nPatch-to-Reboot Compliance:"
$report += "-------------------------"
$report += "Days between last patch and reboot: {0}" -f $daysSinceReboot
if ($daysSinceReboot -gt 7) {
$report += "WARNING: More than 7 days between last patch and reboot!`n"
}
}
} else {
$report += "Unable to retrieve patch information"
}
# Check services
Write-Verbose "Checking AD services..."
$report += "`nActive Directory Services Status:"
$report += "------------------------------"
$services = @("NTDS", "NetLogon", "DNS", "KDC", "DFS", "ADWS")
foreach ($serviceName in $services) {
$svc = Get-RemoteData -ComputerName $DomainController -WMIClass "Win32_Service" -Credential $Credential -Filter "Name='$serviceName'"
if ($svc) {
$report += "{0}: {1}" -f $svc.DisplayName, $svc.State
} else {
$report += "{0}: Not Found" -f $serviceName
}
}
# Get disk space information
Write-Verbose "Getting disk space information..."
$report += "`nDisk Space Information:"
$report += "---------------------"
$disks = Get-RemoteData -ComputerName $DomainController -WMIClass "Win32_LogicalDisk" -Credential $Credential -Filter "DriveType=3"
if ($disks) {
foreach ($disk in $disks) {
$freeSpaceGB = [math]::Round($disk.FreeSpace/1GB, 2)
$totalSpaceGB = [math]::Round($disk.Size/1GB, 2)
$freeSpacePercent = [math]::Round(($disk.FreeSpace/$disk.Size)*100, 2)
$report += "`nDrive {0}:" -f $disk.DeviceID
$report += "Total Space: {0} GB" -f $totalSpaceGB
$report += "Free Space: {0} GB" -f $freeSpaceGB
$report += "Free Space Percentage: {0}%" -f $freeSpacePercent
if ($freeSpacePercent -lt 20) {
$report += "WARNING: Free space is below 20%!"
}
}
} else {
$report += "Unable to retrieve disk space information"
}
# Check replication
Write-Verbose "Checking replication status..."
$report += "`nReplication Status:"
try {
$replStatus = repadmin /showrepl $DomainController /csv | ConvertFrom-Csv
$failedReplications = $replStatus | Where-Object { $_."Number of Failures" -gt 0 }
if ($failedReplications) {
$report += "WARNING: Found failed replications!"
foreach ($fail in $failedReplications) {
$report += "Source DC: {0} - Destination DC: {1}" -f $fail.'Source DC', $fail.'Destination DC'
}
} else {
$report += "Replication status: Healthy"
}
} catch {
$report += "Unable to check replication status: $($_.Exception.Message)"
}
} catch {
$errorMessage = "Error processing {0}: {1}" -f $DomainController, $_.Exception.Message
Write-Error $errorMessage
$report += "`nError: $errorMessage"
}
return $report
}
# Process each Domain Controller
$totalDCs = $DomainControllers.Count
$currentDC = 0
foreach ($dc in $DomainControllers) {
$currentDC++
Write-Verbose "`nProcessing DC $currentDC of $totalDCs : $dc"
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm"
$outputFile = Join-Path $systemHealthPath "DC_Health_Status_${dc}_$timestamp.txt"
try {
$report = Get-DCHealth -DomainController $dc -Credential $Credential -Verbose
$report | Out-File -FilePath $outputFile -Encoding UTF8
Write-Verbose "Health check completed for $dc. Report saved to: $outputFile"
} catch {
Write-Error "Failed to process $dc : $($_.Exception.Message)"
}
}
Write-Verbose "`nHealth check process completed. Reports saved in: $systemHealthPath"
Generating the HTML
Now we need to generate the HTML for the visual HTML report this will use the data in the folder as created in the previous script, this report will be saved to the same directory from where the script is run.
Script : HTMLHealthCards.ps1
# Set the path to the SystemHealth folder and output file
$healthFolder = ".\SystemHealth"
$outputPath = ".\SystemHealthReport.html"
# CSS styles for the report
$styles = @"
<style>
body {
font-family: 'Segoe UI', Arial, sans-serif;
margin: 0;
padding: 20px;
background: #f0f2f5;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 {
margin: 0;
color: #1a1a1a;
font-size: 24px;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
}
.tab {
padding: 12px 24px;
cursor: pointer;
background: #fff;
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 14px;
color: #666;
transition: all 0.3s;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.tab:hover {
background: #f8f9fa;
transform: translateY(-1px);
}
.tab.active {
background: #0066cc;
color: white;
}
.tab-content {
display: none;
background: transparent;
}
.tab-content.active {
display: block;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: 10px;
}
.status-green { background: #10b981; }
.status-amber { background: #f59e0b; }
.status-red { background: #ef4444; }
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.status-item:last-child {
border-bottom: none;
}
.patch-status {
font-weight: 500;
margin-bottom: 8px;
}
.metric-value {
font-size: 14px;
color: #666;
}
.timestamp {
font-size: 12px;
color: #666;
text-align: right;
margin-top: 20px;
}
</style>
"@
# JavaScript for tab functionality
$javascript = @"
<script>
function openServer(evt, serverName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName('tab-content');
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = 'none';
}
tablinks = document.getElementsByClassName('tab');
for (i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(' active', '');
}
document.getElementById(serverName).style.display = 'block';
evt.currentTarget.className += ' active';
}
// Automatically click the first tab on load
window.onload = function() {
document.querySelector('.tab').click();
}
</script>
"@
# Function to get server name from filename
function Get-ServerNameFromFile {
param (
[string]$fileName
)
if ($fileName -match "DC_Health_Status_(st1w\d+)\.") {
return $matches[1].ToUpper()
}
return $null
}
# Function to determine status color
function Get-StatusColor {
param (
[string]$status,
[double]$percentage = -1,
[int]$daysAfterPatch = -1
)
if ($daysAfterPatch -ge 0) {
if ($daysAfterPatch -le 7) { return "status-green" }
else { return "status-red" }
}
if ($percentage -ge 0) {
if ($percentage -gt 20) { return "status-green" }
elseif ($percentage -gt 10) { return "status-amber" }
else { return "status-red" }
}
# Make status check case-insensitive
$statusLower = $status.ToLower().Trim()
switch -Wildcard ($statusLower) {
"running" { return "status-green" }
"healthy" { return "status-green" }
"warning" { return "status-amber" }
"stopped" { return "status-red" }
default { return "status-red" }
}
}
# Initialize HTML content
$currentTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$html = @"
<!DOCTYPE html>
<html>
<head>
<title>Domain Controller Health Status</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
$styles
</head>
<body>
<div class="container">
<div class="header">
<h1>Domain Controller Health Status</h1>
</div>
"@
# Get all health report files
$files = Get-ChildItem -Path $healthFolder -Filter "DC_Health_Status_st1w*.txt"
# Calculate healthy/unhealthy counts
$totalServers = $files.Count
$healthyServers = 0
$unhealthyServers = 0
foreach ($file in $files) {
$content = Get-Content $file.FullName -Raw
$serverName = Get-ServerNameFromFile $file.Name
if (-not $serverName) { continue }
$patchToReboot = [int]([regex]::Match($content, "Days between last patch and reboot: (-?\d+)").Groups[1].Value)
$replicationStatus = if ($content -match "Replication status: (.+?)(?:\n|$)") {
$matches[1].Trim()
} else { "Unknown" }
$services = [regex]::Matches($content, "([^:\n]+): (Running|Stopped)[^\n]*")
$stoppedServices = ($services | Where-Object { $_.Groups[2].Value.Trim() -eq "Stopped" }).Count
$diskMatches = [regex]::Matches($content,
"Drive ([A-Z])::\r?\nTotal Space: ([\d.]+) GB\r?\nFree Space: ([\d.]+) GB\r?\nFree Space Percentage: ([\d.]+)%")
$lowDiskSpace = ($diskMatches | Where-Object { [double]$_.Groups[4].Value -lt 20 }).Count
if (
$patchToReboot -gt 7 -or
$stoppedServices -gt 0 -or
$lowDiskSpace -gt 0 -or
$replicationStatus.ToLower() -ne "healthy"
) {
$unhealthyServers++
} else {
$healthyServers++
}
}
# Add overview status section
$html += @"
<div class="grid" style="margin-bottom: 20px; grid-template-columns: repeat(2, 1fr);">
<div class="card">
<div class="card-title">
Healthy Servers
<span class="status-dot status-green"></span>
</div>
<div style="font-size: 24px; text-align: center; color: #10b981;">
$healthyServers
</div>
</div>
<div class="card">
<div class="card-title">
Unhealthy Servers
<span class="status-dot status-red"></span>
</div>
<div style="font-size: 24px; text-align: center; color: #ef4444;">
$unhealthyServers
</div>
</div>
</div>
<div class="tabs">
"@
# Generate tabs
$firstServer = $true
foreach ($file in $files) {
$serverName = Get-ServerNameFromFile $file.Name
if ($serverName) {
$activeClass = if ($firstServer) { " active" } else { "" }
$html += "<button class='tab$activeClass' onclick=`"openServer(event, '$serverName')`">$serverName</button>`n"
}
}
$html += "</div>`n"
# Process each file for detailed content
foreach ($file in $files) {
$content = Get-Content $file.FullName -Raw
$serverName = Get-ServerNameFromFile $file.Name
if (-not $serverName) { continue }
$activeClass = if ($firstServer) { " active" } else { "" }
# Parse all required data
$lastBoot = [regex]::Match($content, "Last Boot: (.*?)\n").Groups[1].Value
$uptime = [regex]::Match($content, "Current Uptime: (.*?)\n").Groups[1].Value
$patchToReboot = [int]([regex]::Match($content, "Days between last patch and reboot: (-?\d+)").Groups[1].Value)
# Get patches
$patches = [regex]::Matches($content, "KB\d+ - Installed: .*?\n") | ForEach-Object { $_.Value.Trim() }
# Get services
$services = [regex]::Matches($content, "([^:\n]+): (Running|Stopped)[^\n]*") | ForEach-Object {
@{
Name = $_.Groups[1].Value.Trim()
Status = $_.Groups[2].Value.Trim()
}
}
# Get disk information
$diskMatches = [regex]::Matches($content,
"Drive ([A-Z])::\r?\nTotal Space: ([\d.]+) GB\r?\nFree Space: ([\d.]+) GB\r?\nFree Space Percentage: ([\d.]+)%")
$disks = $diskMatches | ForEach-Object {
@{
Drive = $_.Groups[1].Value
Total = "$($_.Groups[2].Value) GB"
Free = "$($_.Groups[3].Value) GB"
Percentage = [double]$_.Groups[4].Value
}
}
# Get replication status and determine message
$replicationStatus = if ($content -match "Replication status: (.+?)(?:\n|$)") {
$matches[1].Trim()
} else {
"Unknown"
}
$replicationMessage = if ($replicationStatus.ToLower() -eq "healthy") {
"Replication Status reported no errors"
} else {
"Replication Status has detected errors please investigate"
}
# Generate tab content
$html += @"
<div id="$serverName" class="tab-content$activeClass">
<div class="grid">
<!-- System Status -->
<div class="card">
<div class="card-title">
System Status
<span class="status-dot $(Get-StatusColor -daysAfterPatch $patchToReboot)"></span>
</div>
<div class="status-item">
<span>Last Boot</span>
<span class="metric-value">$lastBoot</span>
</div>
<div class="status-item">
<span>Current Uptime</span>
<span class="metric-value">$uptime</span>
</div>
<div class="status-item">
<span>Days Since Last Patch</span>
<span class="metric-value">$patchToReboot days</span>
</div>
</div>
<!-- Patch History -->
<div class="card">
<div class="card-title">Recent Patches</div>
"@
foreach ($patch in $patches) {
$html += "<div class='patch-status'>$patch</div>"
}
$html += @"
</div>
<!-- Services Status -->
<div class="card">
<div class="card-title">Services Status</div>
"@
foreach ($service in $services) {
$statusColor = Get-StatusColor -status $service.Status
$html += @"
<div class="status-item">
<span>$($service.Name)</span>
<span class="status-dot $statusColor"></span>
</div>
"@
}
$html += @"
</div>
<!-- Disk Space -->
<div class="card">
<div class="card-title">Disk Space</div>
"@
foreach ($disk in $disks) {
$statusColor = Get-StatusColor -percentage $disk.Percentage
$html += @"
<div class="status-item">
<div>
<div>Drive $($disk.Drive):</div>
<div class="metric-value">Free: $($disk.Free) of $($disk.Total) ($($disk.Percentage)%)</div>
</div>
<span class="status-dot $statusColor"></span>
</div>
"@
}
$html += @"
</div>
<!-- Replication Status -->
<div class="card">
<div class="card-title">
Replication Status
<span class="status-dot $(Get-StatusColor -status $replicationStatus)"></span>
</div>
<div class="status-item">
<span class="metric-value">$replicationMessage</span>
</div>
</div>
</div>
</div>
"@
$firstServer = $false
}
# Close HTML
$html += @"
<div class="timestamp">Report generated: $currentTime</div>
</div>
$javascript
</body>
</html>
"@
# Save the report
$html | Out-File -FilePath $outputPath -Encoding UTF8
Write-Host "Report generated at: $outputPath"
Automation : Task Scheduler
Both of these scripts run very quickly therefore I would recommend that you have them on a scheduled task to keep the data up to date, obviously you need to run the collection script before you run the HTML script - so the data presented is generated from the latest report.