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  = Site\DC 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:\Temp\ADReplicationMatrix.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 = SITE\DC" -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)"
    }
}