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 and, for each domain
    controller, collects the following by targeting it directly
    from the machine running this script (Dcdiag's /s: switch and
    WMI's -ComputerName both work remotely on their own):
 
    - DCDiag tests: Connectivity, DFSREvent, KccEvent, FSMO,
      NetLogons, Replication
    - OS drive free space (GB)
    - CPU usage (%)
    - Memory usage (%)
    - Uptime (days)
 
    Read-only. Uses WMI/DCOM and Dcdiag.exe remote targeting only -
    no PSRemoting/WinRM is used or required, since WinRM is assumed
    blocked in this environment. Outputs a colour-coded HTML report
    to a central network share and optionally sends it by email.
    Each domain is collected in isolation so one unreachable domain
    does not prevent the report from covering the rest.
 
.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: 9.0
    Created: 26-02-2026
    Run as Domain Admin or equivalent with WMI/DCOM access to all domain
    controllers. WinRM/PSRemoting is not used.
 
    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 ============

    Set-StrictMode -Version Latest
    $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)

    # ========== DATA COLLECTION SCRIPTBLOCK ==========
    # Invoked locally per domain. Every remote touchpoint inside
    # (Dcdiag /s:, Get-WmiObject -ComputerName) targets DCs directly
    # and does not require WinRM/PSRemoting.

    $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 ==========
    # Each domain is isolated in its own try/catch so one unreachable
    # domain does not abort collection for the rest of the forest.

    $perDomainResults = @{}
    $failedDomains     = [System.Collections.Generic.List[string]]::new()

    foreach ($domain in $allDomains) {
        Write-Host "Collecting health data from domain '$domain'..."
        try {
            $perDomainResults[$domain] = & $domainHealthScriptBlock -DomainName $domain
        }
        catch {
            Write-Warning "Failed to collect health data for domain '$domain': $($_.Exception.Message)"
            $perDomainResults[$domain] = @()
            $failedDomains.Add($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'>$(ConvertTo-ADOKXmlEscaped ([string]$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;'>$(ConvertTo-ADOKXmlEscaped ([string]$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 &mdash; karanth.ovh
</p>
</body></html>
"@


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

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

        if ($domain -in $failedDomains) {
            $table += "<p style='color:#D9534F;font-weight:bold;'>Collection failed for this domain - see console/log output. No data available.</p>"
            $table
            continue
        }

        $table += $tableHeader

        foreach ($dc in $perDomainResults[$domain]) {
            $row  = '<tr>'
            $row += "<td style='text-align:center;'><b>$(ConvertTo-ADOKXmlEscaped $dc.Server)</b></td>"
            $row += "<td style='text-align:center;'>$(ConvertTo-ADOKXmlEscaped $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 -LiteralPath $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null }
    $htmlBody | Out-File -LiteralPath $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
}