Public/Get-ADForestHealth.ps1

function Get-ADForestHealth {
<#
.SYNOPSIS
    Generates an HTML health report for all domain controllers
    across an Active Directory forest.

.DESCRIPTION
    Discovers all domains in the forest, queries each domain's
    PDC Emulator via Invoke-Command, and collects the following
    per domain controller:

    - DCDiag tests: Connectivity, DFSREvent, KccEvent, FSMO,
      NetLogons, Replication
    - OS drive free space (GB)
    - CPU usage (%)
    - Memory usage (%)
    - Uptime (days)

    Uses WMI for hardware metrics so it works even when
    WinRM is blocked. Outputs a colour-coded HTML report
    to a central network share and optionally sends it
    by email.

.PARAMETER OutputFolder
    Folder where the HTML report file is written.
    Defaults to $env:TEMP.

.EXAMPLE
    Get-ADForestHealth
    Runs the health check and writes the HTML report to $env:TEMP.

.EXAMPLE
    Get-ADForestHealth -OutputFolder "\\ServerName\C$\Scripts\HealthCheck\Reports"
    Runs the health check and writes to a network share.

.NOTES
    Author: K Shankar R Karanth
    Website: https://karanth.ovh
    Version: 8.0
    Created: 26-02-2026
    Run as Domain Admin or equivalent with WinRM access to PDC Emulators
    and WMI access to all domain controllers.

    To enable email reporting, uncomment the Send-MailMessage
    block at the bottom and update $smtpsettings.
#>


    [CmdletBinding()]
    param(
        [string]$OutputFolder = 'C:\ADOpsKit\Reports\Get-ADForestHealth'
    )

    # ============ CONFIGURATION ============

    $now              = Get-Date
    $reportTime       = $now
    $allDomains       = (Get-ADForest).Domains

    # ============ EMAIL CONFIGURATION ============
    # Update these values before enabling email reporting

    # $smtpSettings = @{
    # To = 'recipient@example.com'
    # From = 'sender@example.com'
    # Subject = "$reportEmailSubject - $date"
    # SmtpServer = 'smtp.example.com'
    # Port = 25
    # }

    # ============ OUTPUT CONFIGURATION ============

    $forestName   = (Get-ADForest).Name
    $safeForest   = $forestName -replace '[\\/:*?"<>| ]', '_'
    $outFile      = Join-Path $OutputFolder ("$(Get-Date -Format 'yyyy-MM-dd')_ADHealth_{0}.html" -f $safeForest)

    # ========== DISCOVER PDC EMULATORS ==========

    $domainPDCs = @{}
    foreach ($domain in $allDomains) {
        $domainPDCs[$domain] = (Get-ADDomain -Server $domain).PDCEmulator
    }

    # ========== DATA COLLECTION SCRIPTBLOCK ==========
    # Runs remotely on each PDC Emulator via Invoke-Command

    $domainHealthScriptBlock = {
        param([string]$DomainName)

        Import-Module ActiveDirectory -ErrorAction Stop

        function Get-AllDomainControllers {
            param($ComputerName)
            Get-ADDomainController -Filter * -Server $ComputerName | Sort-Object HostName
        }

        function Get-DCUptimeDays {
            param($ComputerName)
            if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) { return 'Fail' }
            try {
                $os       = Get-WmiObject Win32_OperatingSystem -ComputerName $ComputerName -ErrorAction Stop
                $lastBoot = $os.ConvertToDateTime($os.LastBootUpTime)
                return (New-TimeSpan -Start $lastBoot -End (Get-Date)).Days
            }
            catch { return 'WMI Failure' }
        }

        function Get-DCDiagResults {
            param($ComputerName)

            $results = [PSCustomObject]@{
                ServerName         = $ComputerName
                Connectivity       = $null
                DFSREvent          = $null
                KccEvent           = $null
                KnowsOfRoleHolders = $null
                NetLogons          = $null
                ObjectsReplicated  = $null
            }

            if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) {
                foreach ($prop in $results.PSObject.Properties.Name) {
                    if ($prop -ne 'ServerName') { $results.$prop = 'Failed' }
                }
                return $results
            }

            $params = @(
                "/s:$ComputerName",
                '/test:Connectivity',
                '/test:DFSREvent',
                '/test:KccEvent',
                '/test:KnowsOfRoleHolders',
                '/test:NetLogons',
                '/test:ObjectsReplicated'
            )

            $dcdiagOutput = (Dcdiag.exe @params) -split '[\r\n]'
            $testName     = $null
            $testStatus   = $null

            foreach ($line in $dcdiagOutput) {
                if ($line -match 'Starting test:') {
                    $testName = ($line -replace '.*Starting test:').Trim()
                }
                if ($line -match 'passed test|failed test') {
                    $testStatus = if ($line -match 'passed test') { 'Passed' } else { 'Failed' }
                }
                if ($testName -and $testStatus) {
                    if ($results.PSObject.Properties.Name -contains $testName) {
                        $results.$testName = $testStatus
                    }
                    $testName   = $null
                    $testStatus = $null
                }
            }

            return $results
        }

        function Get-DCOSDriveFreeSpaceGB {
            param($ComputerName)
            if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) { return 'Fail' }
            try {
                $os     = Get-WmiObject Win32_OperatingSystem -ComputerName $ComputerName -ErrorAction Stop
                $drive  = Get-WmiObject Win32_LogicalDisk -ComputerName $ComputerName `
                            -Filter "DeviceID='$($os.SystemDrive)'" -ErrorAction Stop
                return [math]::Round($drive.FreeSpace / 1GB, 2)
            }
            catch { return 'WMI Failure' }
        }

        function Get-DCCPUUsage {
            param($ComputerName)
            if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) { return 'Fail' }
            try {
                $avg = Get-WmiObject Win32_Processor -ComputerName $ComputerName -ErrorAction Stop |
                       Measure-Object -Property LoadPercentage -Average |
                       Select-Object -ExpandProperty Average
                return [math]::Round($avg, 2)
            }
            catch { return 'WMI Failure' }
        }

        function Get-DCMemoryUsage {
            param($ComputerName)
            if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) { return 'Fail' }
            try {
                $os    = Get-WmiObject Win32_OperatingSystem -ComputerName $ComputerName -ErrorAction Stop
                $used  = $os.TotalVisibleMemorySize - $os.FreePhysicalMemory
                return [math]::Round(($used / $os.TotalVisibleMemorySize) * 100, 2)
            }
            catch { return 'WMI Failure' }
        }

        $results = [System.Collections.Generic.List[PSCustomObject]]::new()

        foreach ($dc in (Get-AllDomainControllers $DomainName)) {
            $diag = Get-DCDiagResults $dc.HostName

            $results.Add([PSCustomObject]@{
                Server                 = ($dc.HostName.Split('.')[0]).ToUpper()
                Site                   = $dc.Site
                'DCDIAG: Connectivity' = $diag.Connectivity
                'DCDIAG: DFSREvent'    = $diag.DFSREvent
                'DCDIAG: KccEvent'     = $diag.KccEvent
                'DCDIAG: FSMO'         = $diag.KnowsOfRoleHolders
                'DCDIAG: NetLogons'    = $diag.NetLogons
                'Replication'          = $diag.ObjectsReplicated
                'OS Free Space (GB)'   = Get-DCOSDriveFreeSpaceGB $dc.HostName
                'CPU Usage (%)'        = Get-DCCPUUsage           $dc.HostName
                'Memory Usage (%)'     = Get-DCMemoryUsage        $dc.HostName
                'Uptime (days)'        = Get-DCUptimeDays         $dc.HostName
            })
        }

        return $results
    }

    # ========== COLLECT DATA FROM ALL DOMAINS ==========

    $perDomainResults = @{}
    foreach ($domain in $allDomains) {
        $pdc = $domainPDCs[$domain]
        Write-Host "Collecting health data from domain '$domain' via PDC '$pdc'..."
        $perDomainResults[$domain] = Invoke-Command -ComputerName $pdc `
            -ScriptBlock $domainHealthScriptBlock -ArgumentList $domain
    }

    # ========== HTML HELPER FUNCTION ==========

    function New-StatusCell {
        param(
            $Value,
            [string]$Width = '70px'
        )
        $style = "height:25px;width:$Width;border:1px solid #000;padding:6px;text-align:center;"
        $color = switch ($Value) {
            { $_ -in 'Success','Passed','Pass' } { 'background-color:#6BBF59;color:#000;' }
            'Warn'                               { 'background-color:#FFD966;color:#000;' }
            { $_ -in 'Fail','Failed' }           { 'background-color:#D9534F;color:#fff;' }
            default                              { '' }
        }
        return "<td style='$style$color'>$Value</td>"
    }

    function New-MetricCell {
        param($Value, [double]$WarnThreshold, [double]$DangerThreshold, [string]$Width = '70px')
        $style = "height:25px;width:$Width;border:1px solid #000;padding:6px;text-align:center;"
        if ($Value -is [double] -or $Value -is [int]) {
            $color = if     ($Value -le $WarnThreshold)   { 'background-color:#6BBF59;color:#000;' }
                     elseif ($Value -le $DangerThreshold)  { 'background-color:#FFD966;color:#000;' }
                     else                                  { 'background-color:#D9534F;color:#fff;' }
            return "<td style='$style$color'>$Value</td>"
        }
        return "<td style='${style}background-color:#D9534F;color:#fff;'>$Value</td>"
    }

    # ========== BUILD HTML REPORT ==========

    $htmlHead = @"
<html>
<body style='font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;font-size:10pt;'>
<h1 style='font-size:20px;'>Domain Controller Health Check Report</h1>
<h3 style='font-size:14px;'>Generated: $reportTime</h3>
"@


    $tableHeader = @"
<table border='1' cellpadding='0' cellspacing='0'
  style='width:1300px;border-collapse:collapse;font-size:10pt;table-layout:fixed;'>
<tr style='background-color:#f2f2f2;'>
  <th style='width:120px;'>Server</th>
  <th style='width:110px;'>Site</th>
  <th style='width:70px;'>Connectivity</th>
  <th style='width:70px;'>DFSREvent</th>
  <th style='width:70px;'>KccEvent</th>
  <th style='width:70px;'>FSMO</th>
  <th style='width:70px;'>NetLogons</th>
  <th style='width:70px;'>Replication</th>
  <th style='width:70px;'>OS Free Space (GB)</th>
  <th style='width:70px;'>CPU Usage (%)</th>
  <th style='width:70px;'>Memory Usage (%)</th>
  <th style='width:70px;'>Uptime (days)</th>
</tr>
"@


    $explanationTable = @"
<h3 style='color:#0056b3;margin-top:30px;'>Column Reference</h3>
<table border='1' cellpadding='4' cellspacing='0'
  style='border-collapse:collapse;width:50%;font-size:12px;'>
  <thead><tr style='background-color:#f2f2f2;'>
    <th>Field</th><th>Description</th>
  </tr></thead>
  <tbody>
    <tr><td>Connectivity</td><td>Checks basic connectivity between DCs.</td></tr>
    <tr><td>DFSREvent</td><td>Checks DFS Replication health for SYSVOL.</td></tr>
    <tr><td>KccEvent</td><td>Checks KCC event log for replication topology errors.</td></tr>
    <tr><td>FSMO</td><td>Confirms the DC knows all FSMO role holders.</td></tr>
    <tr><td>NetLogons</td><td>Validates the secure channel via Netlogon.</td></tr>
    <tr><td>Replication</td><td>Confirms AD objects replicate correctly.</td></tr>
    <tr><td>OS Free Space (GB)</td><td>Available disk space on the system drive.</td></tr>
    <tr><td>CPU Usage (%)</td><td>Current CPU utilisation. Warn >75%, Fail >90%.</td></tr>
    <tr><td>Memory Usage (%)</td><td>Current RAM utilisation. Warn >75%, Fail >90%.</td></tr>
    <tr><td>Uptime (days)</td><td>Days since last reboot. Warn >30 days, Fail >45 days.</td></tr>
  </tbody>
</table>
"@


    $htmlTail = @"
<p style='font-size:11px;color:#555;margin-top:20px;'>
  Report generated by Get-ADForestHealth — karanth.ovh
</p>
</body></html>
"@


    # --------- ASSEMBLE PER-DOMAIN TABLES ---------

    $allDomainTables = foreach ($domain in $allDomains) {
        $table = "<h2 style='color:#174ea6;'>Domain: $domain</h2>" + $tableHeader

        foreach ($dc in $perDomainResults[$domain]) {
            $row  = '<tr>'
            $row += "<td style='text-align:center;'><b>$($dc.Server)</b></td>"
            $row += "<td style='text-align:center;'>$($dc.Site)</td>"
            $row += New-StatusCell $dc.'DCDIAG: Connectivity'
            $row += New-StatusCell $dc.'DCDIAG: DFSREvent'
            $row += New-StatusCell $dc.'DCDIAG: KccEvent'
            $row += New-StatusCell $dc.'DCDIAG: FSMO'
            $row += New-StatusCell $dc.'DCDIAG: NetLogons'
            $row += New-StatusCell $dc.'Replication'
            $row += New-MetricCell $dc.'OS Free Space (GB)' -WarnThreshold 40  -DangerThreshold 20
            $row += New-MetricCell $dc.'CPU Usage (%)'      -WarnThreshold 75  -DangerThreshold 90
            $row += New-MetricCell $dc.'Memory Usage (%)'   -WarnThreshold 75  -DangerThreshold 90
            $row += New-MetricCell $dc.'Uptime (days)'      -WarnThreshold 30  -DangerThreshold 45
            $row += '</tr>'
            $table += $row
        }

        $table + '</table>'
    }

    $htmlBody = $htmlHead + ($allDomainTables -join '<br/><br/>') + $explanationTable + $htmlTail

    # ========== OUTPUT ==========

    if (-not (Test-Path $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null }
    $htmlBody | Out-File -FilePath $outFile -Encoding UTF8
    Write-Host "Report written to: $outFile" -ForegroundColor Green

    # ========== EMAIL (uncomment to enable) ==========
    # Send-MailMessage @smtpSettings -Body $htmlBody -BodyAsHtml `
    # -Encoding ([System.Text.Encoding]::UTF8) -ErrorAction Stop
    # Write-Host "Email sent successfully." -ForegroundColor Green
}