Skip to main content
Print

Microsoft Active Directory Replication

You can use repladmin to get information. I’ll edit this to show that later.

For now, my gift to the internet is a script to get a matrix of latencies in replication and a quick-reference for FSMO assignments.

The result is something that looks like this:

Here’s the full script:

<#
.SYNOPSIS
    Shows Active Directory replication status as source/destination matrices.

.DESCRIPTION
    Uses Get-ADReplicationPartnerMetadata to collect replication metadata,
    then builds:
      - One intra-site matrix per AD site
      - One inter-site matrix showing only DCs participating in site-to-site replication
      - FSMO role holder summary

    Site-specific grids:
      Rows    = Destination DCs in the site
      Columns = Source DCs in the site
      Labels  = DC display names only

    Inter-site grid:
      Rows    = All DCs participating in inter-site replication
      Columns = All DCs participating in inter-site replication
      Labels  = SiteDC display names

    Cell = Time since last successful sync.

.NOTES
    Requires:
      - RSAT Active Directory PowerShell module
      - Domain permissions to query replication metadata

.EXAMPLE
    .Show-ADReplicationMatrix.ps1

.EXAMPLE
    .Show-ADReplicationMatrix.ps1 -ShowDetails

.EXAMPLE
    .Show-ADReplicationMatrix.ps1 -ExportCsv C:TempADReplicationMatrix.csv

.EXAMPLE
    .Show-ADReplicationMatrix.ps1 -IncludeNamingContext "DC=bwicompanies,DC=com"
#>

param(
    [string]$IncludeNamingContext,

    [string]$ExportCsv,

    [int]$WarningMinutes = 20,

    [int]$CriticalMinutes = 61,

    [switch]$ShowDetails
)

# ------------------------------------------------------------
# Optional display name mappings.
# If a value is not mapped, the raw value is displayed.
# ------------------------------------------------------------

$SiteNameMap = @{
    "COR"      = "Corporate"
}

$DcNameMap = @{

}

# ------------------------------------------------------------
# Helper functions
# ------------------------------------------------------------

function Get-ShortName {
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Name
    )

    if ([string]::IsNullOrWhiteSpace($Name)) {
        return ""
    }

    return (($Name.Trim() -split ".")[0]).ToUpper()
}

function Get-SourceDcNameFromPartner {
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Partner
    )

    if ([string]::IsNullOrWhiteSpace($Partner)) {
        return ""
    }

    # Typical format:
    # CN=NTDS Settings,CN=DC1,CN=Servers,CN=SITE,CN=Sites,CN=Configuration,...
    if ($Partner -match 'CN=NTDS Settings,CN=([^,]+),CN=Servers') {
        return $matches[1].ToUpper()
    }

    # Sometimes it may be a server DN directly.
    if ($Partner -match 'CN=([^,]+),CN=Servers') {
        return $matches[1].ToUpper()
    }

    # Fallback for FQDN-ish values.
    return Get-ShortName -Name $Partner
}

function Get-SiteNameFromPartner {
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Partner
    )

    if ([string]::IsNullOrWhiteSpace($Partner)) {
        return ""
    }

    # Typical format:
    # CN=NTDS Settings,CN=DC1,CN=Servers,CN=SITE,CN=Sites,CN=Configuration,...
    if ($Partner -match 'CN=Servers,CN=([^,]+),CN=Sites') {
        return $matches[1]
    }

    return ""
}

function Resolve-DisplayName {
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Value,

        [hashtable]$Map
    )

    if ([string]::IsNullOrWhiteSpace($Value)) {
        return ""
    }

    if ($Map -and $Map.ContainsKey($Value)) {
        return $Map[$Value]
    }

    return $Value
}

function Get-DcDisplayName {
    param(
        [string]$Name
    )

    return Resolve-DisplayName -Value $Name -Map $DcNameMap
}

function Get-SiteDisplayName {
    param(
        [string]$Site
    )

    return Resolve-DisplayName -Value $Site -Map $SiteNameMap
}

function Get-FullDisplayName {
    param(
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Site,

        [string]$Name
    )

    $dcDisplay = Get-DcDisplayName -Name $Name

    if ([string]::IsNullOrWhiteSpace($Site)) {
        return $dcDisplay
    }

    $siteDisplay = Get-SiteDisplayName -Site $Site

    return "$siteDisplay$dcDisplay"
}

function Convert-ToElapsedLabel {
    param(
        [AllowNull()]
        [datetime]$LastSuccess
    )

    if ($null -eq $LastSuccess -or $LastSuccess -eq [datetime]::MinValue) {
        return "Never"
    }

    $span = New-TimeSpan -Start $LastSuccess -End (Get-Date)

    if ($span.TotalDays -ge 1) {
        return "{0}d {1}h" -f [math]::Floor($span.TotalDays), $span.Hours
    }

    if ($span.TotalHours -ge 1) {
        return "{0}h {1}m" -f [math]::Floor($span.TotalHours), $span.Minutes
    }

    return "{0}m" -f [math]::Max(0, [math]::Floor($span.TotalMinutes))
}

function Write-ColoredCell {
    param(
        [string]$Text,
        $AgeMinutes,
        [int]$Width,
        [int]$WarningMinutes,
        [int]$CriticalMinutes
    )

    if ([string]::IsNullOrWhiteSpace($Text)) {
        $Text = "."
    }

    if ($Text.Length -gt $Width) {
        $Text = $Text.Substring(0, $Width)
    }

    $padded = $Text.PadRight($Width)

    if ($null -eq $AgeMinutes) {
        Write-Host $padded -NoNewline -ForegroundColor DarkGray
        return
    }

    $age = [double]$AgeMinutes

    if ($age -ge $CriticalMinutes) {
        Write-Host $padded -NoNewline -ForegroundColor Red
    }
    elseif ($age -ge $WarningMinutes) {
        Write-Host $padded -NoNewline -ForegroundColor Yellow
    }
    else {
        Write-Host $padded -NoNewline -ForegroundColor Green
    }
}

function Write-DcLabel {
    param(
        [string]$Text,

        [int]$Width,

        [bool]$IsGlobalCatalog,

        [bool]$IsReadOnly
    )

    if ([string]::IsNullOrWhiteSpace($Text)) {
        $Text = ""
    }

    if ($Text.Length -gt $Width) {
        $Text = $Text.Substring(0, $Width)
    }

    $padded = $Text.PadRight($Width)

    # Normal DCs = White
    # GCs        = Cyan
    # RODCs      = Gray
    #
    # If both GC and RODC, RODC wins because operationally that matters more.
    $foregroundColor = "White"

    if ($IsGlobalCatalog) {
        $foregroundColor = "Cyan"
    }

    if ($IsReadOnly) {
        $foregroundColor = "Gray"
    }

    Write-Host $padded -NoNewline -ForegroundColor $foregroundColor
}

function New-CellLookup {
    param(
        [array]$Summary
    )

    $lookup = @{}

    foreach ($cell in $Summary) {
        $key = "$($cell.Destination)|$($cell.Source)"
        $lookup[$key] = $cell
    }

    return $lookup
}

function New-DcEndpoint {
    param(
        [string]$Site,

        [string]$Name,

        [hashtable]$DcLookup
    )

    $isGlobalCatalog = $false
    $isReadOnly = $false

    if ($DcLookup -and $DcLookup.ContainsKey($Name)) {
        $isGlobalCatalog = [bool]$DcLookup[$Name].IsGlobalCatalog
        $isReadOnly = [bool]$DcLookup[$Name].IsReadOnly
    }

    [pscustomobject]@{
        Site            = $Site
        Name            = $Name
        IsGlobalCatalog = $isGlobalCatalog
        IsReadOnly      = $isReadOnly
    }
}

function Get-EndpointDisplayName {
    param(
        [object]$Endpoint,

        [switch]$HideSiteInLabels
    )

    if ($HideSiteInLabels) {
        return Get-DcDisplayName -Name $Endpoint.Name
    }

    return Get-FullDisplayName -Site $Endpoint.Site -Name $Endpoint.Name
}

function Write-ReplicationGrid {
    param(
        [string]$Title,

        [array]$Rows,

        [array]$Columns,

        [hashtable]$CellLookup,

        [int]$WarningMinutes,

        [int]$CriticalMinutes,

        [switch]$HideSiteInLabels
    )

    if (-not $Rows -or -not $Columns) {
        return
    }

    $maxRowLength = 0

    foreach ($row in $Rows) {
        $displayName = Get-EndpointDisplayName -Endpoint $row -HideSiteInLabels:$HideSiteInLabels

        if ($displayName.Length -gt $maxRowLength) {
            $maxRowLength = $displayName.Length
        }
    }

    $maxColumnLength = 0

    foreach ($column in $Columns) {
        $displayName = Get-EndpointDisplayName -Endpoint $column -HideSiteInLabels:$HideSiteInLabels

        if ($displayName.Length -gt $maxColumnLength) {
            $maxColumnLength = $displayName.Length
        }
    }

    $cellWidth = [math]::Max(12, [math]::Min(24, $maxColumnLength + 2))
    $rowHeaderWidth = [math]::Max(20, $maxRowLength + 2)

    Write-Host ""
    Write-Host $Title -ForegroundColor Cyan
    Write-Host "Rows = Destination DCs, Columns = Source DCs" -ForegroundColor DarkCyan

    if ($HideSiteInLabels) {
        Write-Host "Format = DC" -ForegroundColor DarkCyan
    }
    else {
        Write-Host "Format = SITEDC" -ForegroundColor DarkCyan
    }

    Write-Host "Cyan names = Global Catalogs, Gray names = RODCs" -ForegroundColor DarkCyan
    Write-Host ""

    # Header
    Write-Host "".PadRight($rowHeaderWidth) -NoNewline

    foreach ($column in $Columns) {
        $header = Get-EndpointDisplayName -Endpoint $column -HideSiteInLabels:$HideSiteInLabels

        Write-DcLabel `
            -Text $header `
            -Width $cellWidth `
            -IsGlobalCatalog $column.IsGlobalCatalog `
            -IsReadOnly $column.IsReadOnly
    }

    Write-Host ""

    # Separator
    $totalWidth = $rowHeaderWidth + ($Columns.Count * $cellWidth)
    Write-Host ("".PadRight($totalWidth, "-")) -ForegroundColor DarkGray

    # Rows
    foreach ($row in $Rows) {
        $rowName = Get-EndpointDisplayName -Endpoint $row -HideSiteInLabels:$HideSiteInLabels

        Write-DcLabel `
            -Text $rowName `
            -Width $rowHeaderWidth `
            -IsGlobalCatalog $row.IsGlobalCatalog `
            -IsReadOnly $row.IsReadOnly

        foreach ($column in $Columns) {
            $key = "$($row.Name)|$($column.Name)"

            if ($CellLookup.ContainsKey($key)) {
                $cell = $CellLookup[$key]

                Write-ColoredCell `
                    -Text $cell.Cell `
                    -AgeMinutes $cell.AgeMinutes `
                    -Width $cellWidth `
                    -WarningMinutes $WarningMinutes `
                    -CriticalMinutes $CriticalMinutes
            }
            else {
                Write-ColoredCell `
                    -Text "." `
                    -AgeMinutes $null `
                    -Width $cellWidth `
                    -WarningMinutes $WarningMinutes `
                    -CriticalMinutes $CriticalMinutes
            }
        }

        Write-Host ""
    }
}

function Convert-SummaryToExportRows {
    param(
        [array]$Rows,

        [array]$Columns,

        [hashtable]$CellLookup,

        [string]$GridName,

        [switch]$HideSiteInLabels
    )

    foreach ($row in $Rows) {
        $destinationDisplay = Get-EndpointDisplayName -Endpoint $row -HideSiteInLabels:$HideSiteInLabels

        $obj = [ordered]@{
            Grid                       = $GridName
            DestinationSite            = $row.Site
            DestinationSiteName        = Get-SiteDisplayName -Site $row.Site
            Destination                = $row.Name
            DestinationName            = $destinationDisplay
            DestinationIsGlobalCatalog = $row.IsGlobalCatalog
            DestinationIsReadOnly      = $row.IsReadOnly
        }

        foreach ($column in $Columns) {
            $sourceDisplay = Get-EndpointDisplayName -Endpoint $column -HideSiteInLabels:$HideSiteInLabels
            $key = "$($row.Name)|$($column.Name)"

            if ($CellLookup.ContainsKey($key)) {
                $obj[$sourceDisplay] = $CellLookup[$key].Cell
            }
            else {
                $obj[$sourceDisplay] = ""
            }
        }

        [pscustomobject]$obj
    }
}

function Write-FsmoRoleSummary {
    param(
        [hashtable]$DcLookup
    )

    Write-Host ""
    Write-Host "FSMO Role Holders" -ForegroundColor Cyan

    try {
        $domain = Get-ADDomain -ErrorAction Stop
        $forest = Get-ADForest -ErrorAction Stop
    }
    catch {
        Write-Warning "Failed to retrieve FSMO role holders: $($_.Exception.Message)"
        return
    }

    $roleRows = @(
        [pscustomobject]@{
            Scope  = "Forest"
            Role   = "Schema Master"
            Server = Get-ShortName -Name $forest.SchemaMaster
        }
        [pscustomobject]@{
            Scope  = "Forest"
            Role   = "Domain Naming Master"
            Server = Get-ShortName -Name $forest.DomainNamingMaster
        }
        [pscustomobject]@{
            Scope  = "Domain"
            Role   = "PDC Emulator"
            Server = Get-ShortName -Name $domain.PDCEmulator
        }
        [pscustomobject]@{
            Scope  = "Domain"
            Role   = "RID Master"
            Server = Get-ShortName -Name $domain.RIDMaster
        }
        [pscustomobject]@{
            Scope  = "Domain"
            Role   = "Infrastructure Master"
            Server = Get-ShortName -Name $domain.InfrastructureMaster
        }
    )

    $outputRows = foreach ($row in $roleRows) {
        $site = ""
        $isGlobalCatalog = $false
        $isReadOnly = $false

        if ($DcLookup -and $DcLookup.ContainsKey($row.Server)) {
            $site = $DcLookup[$row.Server].Site
            $isGlobalCatalog = [bool]$DcLookup[$row.Server].IsGlobalCatalog
            $isReadOnly = [bool]$DcLookup[$row.Server].IsReadOnly
        }

        [pscustomobject]@{
            Scope           = $row.Scope
            Role            = $row.Role
            Site            = Get-SiteDisplayName -Site $site
            Server          = Get-DcDisplayName -Name $row.Server
            IsGlobalCatalog = $isGlobalCatalog
            IsReadOnly      = $isReadOnly
            RawSite         = $site
            RawServer       = $row.Server
        }
    }

    $scopeWidth = 8
    $roleWidth = 24
    $siteWidth = 18
    $serverWidth = 18

    Write-Host ""
    Write-Host "Scope".PadRight($scopeWidth) -NoNewline -ForegroundColor White
    Write-Host "Role".PadRight($roleWidth) -NoNewline -ForegroundColor White
    Write-Host "Site".PadRight($siteWidth) -NoNewline -ForegroundColor White
    Write-Host "Server".PadRight($serverWidth) -NoNewline -ForegroundColor White
    Write-Host ""

    Write-Host ("".PadRight($scopeWidth + $roleWidth + $siteWidth + $serverWidth, "-")) -ForegroundColor DarkGray

    foreach ($row in ($outputRows | Sort-Object Scope, Role)) {
        Write-Host $row.Scope.PadRight($scopeWidth) -NoNewline -ForegroundColor White
        Write-Host $row.Role.PadRight($roleWidth) -NoNewline -ForegroundColor White
        Write-Host $row.Site.PadRight($siteWidth) -NoNewline -ForegroundColor White

        Write-DcLabel `
            -Text $row.Server `
            -Width $serverWidth `
            -IsGlobalCatalog $row.IsGlobalCatalog `
            -IsReadOnly $row.IsReadOnly

        Write-Host ""
    }
}

# ------------------------------------------------------------
# Load AD module
# ------------------------------------------------------------

Write-Host ""
Write-Host "Loading Active Directory module..." -ForegroundColor Cyan

try {
    Import-Module ActiveDirectory -ErrorAction Stop
}
catch {
    Write-Error "Could not load the ActiveDirectory PowerShell module. Install RSAT AD DS tools or run from a domain controller."
    exit 1
}

# ------------------------------------------------------------
# Get domain controllers
# ------------------------------------------------------------

Write-Host "Getting domain controllers..." -ForegroundColor Cyan

try {
    $domainControllers = Get-ADDomainController -Filter * |
        Sort-Object Site, Name
}
catch {
    Write-Error "Failed to retrieve domain controllers. Error: $($_.Exception.Message)"
    exit 1
}

if (-not $domainControllers) {
    Write-Error "No domain controllers were found. Which would be impressive, but not useful."
    exit 1
}

# Build lookup table:
# DC short name -> site/name/role info
$dcLookup = @{}

foreach ($dc in $domainControllers) {
    $shortName = Get-ShortName -Name $dc.HostName

    if ([string]::IsNullOrWhiteSpace($shortName)) {
        $shortName = Get-ShortName -Name $dc.Name
    }

    if ([string]::IsNullOrWhiteSpace($shortName)) {
        continue
    }

    $dcLookup[$shortName] = [pscustomobject]@{
        Name            = $shortName
        HostName        = $dc.HostName
        Site            = $dc.Site
        IsGlobalCatalog = [bool]$dc.IsGlobalCatalog
        IsReadOnly      = [bool]$dc.IsReadOnly
    }
}

# ------------------------------------------------------------
# Collect replication metadata
# ------------------------------------------------------------

Write-Host "Collecting replication partner metadata..." -ForegroundColor Cyan

$metadata = foreach ($dc in $domainControllers) {
    try {
        Get-ADReplicationPartnerMetadata `
            -Target $dc.HostName `
            -Scope Server `
            -ErrorAction Stop
    }
    catch {
        Write-Warning "Failed to query replication metadata from $($dc.Name): $($_.Exception.Message)"
    }
}

if (-not $metadata) {
    Write-Error "No replication metadata was returned."
    exit 1
}

# ------------------------------------------------------------
# Normalize records
# ------------------------------------------------------------

$records = foreach ($item in $metadata) {
    if ($IncludeNamingContext -and $item.Partition -notlike "*$IncludeNamingContext*") {
        continue
    }

    $destination = Get-ShortName -Name $item.Server
    $source = Get-SourceDcNameFromPartner -Partner $item.Partner

    if ([string]::IsNullOrWhiteSpace($destination) -or
        [string]::IsNullOrWhiteSpace($source)) {
        continue
    }

    $destinationSite = ""
    $sourceSite = ""

    $destinationIsGlobalCatalog = $false
    $destinationIsReadOnly = $false
    $sourceIsGlobalCatalog = $false
    $sourceIsReadOnly = $false

    if ($dcLookup.ContainsKey($destination)) {
        $destinationSite = $dcLookup[$destination].Site
        $destinationIsGlobalCatalog = [bool]$dcLookup[$destination].IsGlobalCatalog
        $destinationIsReadOnly = [bool]$dcLookup[$destination].IsReadOnly
    }

    if ($dcLookup.ContainsKey($source)) {
        $sourceSite = $dcLookup[$source].Site
        $sourceIsGlobalCatalog = [bool]$dcLookup[$source].IsGlobalCatalog
        $sourceIsReadOnly = [bool]$dcLookup[$source].IsReadOnly
    }

    if ([string]::IsNullOrWhiteSpace($sourceSite)) {
        $sourceSite = Get-SiteNameFromPartner -Partner $item.Partner
    }

    $lastSuccess = $item.LastReplicationSuccess
    $ageMinutes = $null

    if ($null -ne $lastSuccess -and $lastSuccess -ne [datetime]::MinValue) {
        $ageMinutes = ((Get-Date) - $lastSuccess).TotalMinutes
    }

    [pscustomobject]@{
        Destination                = $destination
        DestinationSite            = $destinationSite
        DestinationIsGlobalCatalog = $destinationIsGlobalCatalog
        DestinationIsReadOnly      = $destinationIsReadOnly
        Source                     = $source
        SourceSite                 = $sourceSite
        SourceIsGlobalCatalog      = $sourceIsGlobalCatalog
        SourceIsReadOnly           = $sourceIsReadOnly
        NamingContext              = $item.Partition
        LastSuccess                = $lastSuccess
        LastAttempt                = $item.LastReplicationAttempt
        FailureCount               = $item.ConsecutiveReplicationFailures
        LastReplicationResult      = $item.LastReplicationResult
        AgeMinutes                 = $ageMinutes
        AgeLabel                   = Convert-ToElapsedLabel -LastSuccess $lastSuccess
    }
}

if (-not $records) {
    Write-Warning "No replication records found after filtering."
    exit 0
}

# ------------------------------------------------------------
# Pair summary
#
# Multiple naming contexts can exist per source/destination pair.
# Show the oldest valid success time for the pair.
# Only show Never if there are no valid success times at all.
# ------------------------------------------------------------

$pairSummary = $records |
    Group-Object Destination, Source |
    ForEach-Object {
        $items = $_.Group

        $validItems = $items |
            Where-Object {
                $null -ne $_.LastSuccess -and
                $_.LastSuccess -ne [datetime]::MinValue
            }

        if ($validItems) {
            $oldest = $validItems |
                Sort-Object LastSuccess |
                Select-Object -First 1
        }
        else {
            $oldest = $items | Select-Object -First 1
        }

        $failureCount = ($items | Measure-Object FailureCount -Sum).Sum

        if ($null -eq $failureCount) {
            $failureCount = 0
        }

        $label = $oldest.AgeLabel

        if ($failureCount -gt 0) {
            $label = "$label!"
        }

        [pscustomobject]@{
            Destination                = $oldest.Destination
            DestinationSite            = $oldest.DestinationSite
            DestinationIsGlobalCatalog = $oldest.DestinationIsGlobalCatalog
            DestinationIsReadOnly      = $oldest.DestinationIsReadOnly
            Source                     = $oldest.Source
            SourceSite                 = $oldest.SourceSite
            SourceIsGlobalCatalog      = $oldest.SourceIsGlobalCatalog
            SourceIsReadOnly           = $oldest.SourceIsReadOnly
            Cell                       = $label
            AgeMinutes                 = $oldest.AgeMinutes
            FailureCount               = $failureCount
            OldestNamingContext        = $oldest.NamingContext
            LastSuccess                = $oldest.LastSuccess
            LastReplicationResult      = $oldest.LastReplicationResult
        }
    }

$cellLookup = New-CellLookup -Summary $pairSummary

# ------------------------------------------------------------
# Output heading
# ------------------------------------------------------------

Write-Host ""
Write-Host "Active Directory Replication Matrix" -ForegroundColor Cyan
Write-Host "Cell = Time since last successful replication" -ForegroundColor DarkCyan
Write-Host "'!' = One or more consecutive failures reported for that source/destination pair" -ForegroundColor Yellow
Write-Host "Cyan names = Global Catalogs, Gray names = RODCs" -ForegroundColor DarkCyan

$allExportRows = @()

# ------------------------------------------------------------
# Per-site grids
# Only show intra-site replication:
# DestinationSite == SourceSite
# Site is not shown in row/column labels.
# ------------------------------------------------------------

$siteNames = $pairSummary |
    Where-Object {
        -not [string]::IsNullOrWhiteSpace($_.DestinationSite)
    } |
    Select-Object -ExpandProperty DestinationSite -Unique |
    Sort-Object

foreach ($siteName in $siteNames) {
    $siteSummary = $pairSummary |
        Where-Object {
            $_.DestinationSite -eq $siteName -and
            $_.SourceSite -eq $siteName
        }

    if (-not $siteSummary) {
        continue
    }

    $siteRows = $siteSummary |
        ForEach-Object {
            New-DcEndpoint `
                -Site $_.DestinationSite `
                -Name $_.Destination `
                -DcLookup $dcLookup
        } |
        Sort-Object Site, Name -Unique

    $siteColumns = $siteSummary |
        ForEach-Object {
            New-DcEndpoint `
                -Site $_.SourceSite `
                -Name $_.Source `
                -DcLookup $dcLookup
        } |
        Sort-Object Site, Name -Unique

    $siteDisplayName = Get-SiteDisplayName -Site $siteName

    Write-ReplicationGrid `
        -Title "Site Replication Matrix: $siteDisplayName" `
        -Rows $siteRows `
        -Columns $siteColumns `
        -CellLookup $cellLookup `
        -WarningMinutes $WarningMinutes `
        -CriticalMinutes $CriticalMinutes `
        -HideSiteInLabels

    $allExportRows += Convert-SummaryToExportRows `
        -Rows $siteRows `
        -Columns $siteColumns `
        -CellLookup $cellLookup `
        -GridName "Site: $siteDisplayName" `
        -HideSiteInLabels
}

# ------------------------------------------------------------
# Inter-site grid
#
# Only show pairs where source and destination sites differ.
#
# Rows and columns are both built from the UNION of all DCs participating
# in inter-site replication, regardless of whether a DC appeared only as
# a source or only as a destination.
# ------------------------------------------------------------

$interSiteSummary = $pairSummary |
    Where-Object {
        -not [string]::IsNullOrWhiteSpace($_.DestinationSite) -and
        -not [string]::IsNullOrWhiteSpace($_.SourceSite) -and
        $_.DestinationSite -ne $_.SourceSite
    }

if ($interSiteSummary) {
    $interSiteParticipants = @()

    $interSiteParticipants += $interSiteSummary |
        ForEach-Object {
            New-DcEndpoint `
                -Site $_.DestinationSite `
                -Name $_.Destination `
                -DcLookup $dcLookup
        }

    $interSiteParticipants += $interSiteSummary |
        ForEach-Object {
            New-DcEndpoint `
                -Site $_.SourceSite `
                -Name $_.Source `
                -DcLookup $dcLookup
        }

    $interSiteParticipants = $interSiteParticipants |
        Sort-Object Site, Name -Unique

    # Same participants on both axes, so source-only and destination-only
    # bridgehead DCs are both visible. Revolutionary: seeing the data.
    $interSiteRows = $interSiteParticipants
    $interSiteColumns = $interSiteParticipants

    Write-ReplicationGrid `
        -Title "Inter-Site Replication Matrix" `
        -Rows $interSiteRows `
        -Columns $interSiteColumns `
        -CellLookup $cellLookup `
        -WarningMinutes $WarningMinutes `
        -CriticalMinutes $CriticalMinutes

    $allExportRows += Convert-SummaryToExportRows `
        -Rows $interSiteRows `
        -Columns $interSiteColumns `
        -CellLookup $cellLookup `
        -GridName "Inter-Site"
}
else {
    Write-Host ""
    Write-Host "No inter-site replication relationships found." -ForegroundColor Yellow
}

# ------------------------------------------------------------
# FSMO Role Holders
# ------------------------------------------------------------

Write-FsmoRoleSummary -DcLookup $dcLookup

# ------------------------------------------------------------
# Legend
# ------------------------------------------------------------

Write-Host ""
Write-Host "Legend:" -ForegroundColor Cyan
Write-Host "  Green      = under $WarningMinutes Minutes"
Write-Host "  Yellow     = $WarningMinutes-$CriticalMinutes Minutes"
Write-Host "  Red        = over $CriticalMinutes Minutes"
Write-Host "  .          = no direct replication relationship found"
Write-Host "  !          = one or more consecutive replication failures reported"
Write-Host "  Cyan name  = Global Catalog"
Write-Host "  Gray name  = Read-only Domain Controller"
Write-Host ""

# ------------------------------------------------------------
# Optional details
# ------------------------------------------------------------

if ($ShowDetails) {
    Write-Host ""
    Write-Host "Detailed replication records:" -ForegroundColor Cyan

    $records |
        Sort-Object DestinationSite, Destination, SourceSite, Source, NamingContext |
        Select-Object `
            DestinationSite,
            Destination,
            DestinationIsGlobalCatalog,
            DestinationIsReadOnly,
            SourceSite,
            Source,
            SourceIsGlobalCatalog,
            SourceIsReadOnly,
            NamingContext,
            AgeLabel,
            LastSuccess,
            LastAttempt,
            FailureCount,
            LastReplicationResult |
        Format-Table -AutoSize
}

# ------------------------------------------------------------
# Optional CSV export
# ------------------------------------------------------------

if ($ExportCsv) {
    try {
        $allExportRows | Export-Csv -Path $ExportCsv -NoTypeInformation
        Write-Host "Matrix exported to: $ExportCsv" -ForegroundColor Cyan
    }
    catch {
        Write-Error "Failed to export CSV to '$ExportCsv'. Error: $($_.Exception.Message)"
    }
}
Table of Contents