Public/Get-ADReplicationTopologyDiagram.ps1
|
function Get-ADReplicationTopologyDiagram { <# .SYNOPSIS Discovers all domain controllers in the forest, collects their replication partnerships, and produces a self-contained HTML diagram of the topology. .DESCRIPTION Uses only LDAP (port 389) and repadmin.exe - no ADWS, no WinRM required. For every domain controller found the function collects: - Hostname, FQDN, IPv4 address (via DNS) - AD site membership - FSMO roles held (via LDAP fSMORoleOwner attributes) - Operating system (via LDAP computer object) - GC / RODC flags (via NTDS Settings options attribute) - Inbound replication partners and failure counts (repadmin /showrepl) - Site links between sites (LDAP Inter-Site Transports container) Output is a fully self-contained HTML file with an inline SVG diagram. No internet connection or browser plugins required. The function is read-only - it does not modify any AD object. .PARAMETER DomainController FQDN or IP of a domain controller to use for LDAP queries. Defaults to the PDC emulator discovered automatically. .PARAMETER OutputPath Full path for the HTML report file. Defaults to C:\ADOpsKit\Reports\Get-ADReplicationTopologyDiagram\<date>_ADReplicationTopology.html .PARAMETER IncludeAllDomains When specified the function queries every domain in the forest via its crossRef objects, not just the current domain. .EXAMPLE Get-ADReplicationTopologyDiagram .EXAMPLE Get-ADReplicationTopologyDiagram -OutputPath "C:\Reports\topology.html" -IncludeAllDomains .NOTES Author: K Shankar R Karanth Website: https://karanth.ovh Version: 1.0 Requirements: - repadmin.exe (available on any Windows Server or RSAT install) - LDAP port 389 reachable on the target DC - Domain User rights minimum; Replicating Directory Changes for full replication data #> [CmdletBinding()] param( [string]$DomainController, [string]$OutputPath = "C:\ADOpsKit\Reports\Get-ADReplicationTopologyDiagram\$(Get-Date -Format 'yyyy-MM-dd')_ADReplicationTopology.html", [switch]$IncludeAllDomains ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' #region -- verify repadmin is available --------------------------------------- if (-not (Get-Command repadmin.exe -ErrorAction SilentlyContinue)) { throw "repadmin.exe not found. Install RSAT (AD DS Tools) and re-run." } #endregion #region -- resolve target DC via LDAP ----------------------------------------- Write-ADOKStep "Connecting to Active Directory via LDAP ..." if (-not $DomainController) { # Use the .NET ActiveDirectory namespace (LDAP/Kerberos - no ADWS needed) try { $DomainController = ([System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()).PdcRoleOwner.Name } catch { # Fallback: read from the environment and do a DNS lookup $DomainController = $env:LOGONSERVER -replace '\\', '' if (-not $DomainController) { throw "Cannot determine a domain controller. Pass -DomainController explicitly." } } } # Read the Root DSE to get naming context paths $rootDSE = [System.DirectoryServices.DirectoryEntry]::new("LDAP://$DomainController/RootDSE") $defaultNC = [string]$rootDSE.Properties['defaultNamingContext'][0] $configNC = [string]$rootDSE.Properties['configurationNamingContext'][0] $forestNC = [string]$rootDSE.Properties['rootDomainNamingContext'][0] $forestDNS = ConvertFrom-ADOKDistinguishedName $forestNC Write-ADOKOk "DC: $DomainController Forest: $forestDNS" #endregion #region -- collect domains to scan ------------------------------------------- Write-ADOKStep "Enumerating domains ..." # domainInfo: list of @{ DNSRoot; NC; PDC; } $domainsToScan = [System.Collections.Generic.List[hashtable]]::new() if ($IncludeAllDomains) { # crossRef objects in CN=Partitions,CN=Configuration enumerate all domains $crSearcher = New-ADOKLdapSearcher ` -Server $DomainController ` -BaseDN "CN=Partitions,$configNC" ` -Filter "(&(objectClass=crossRef)(systemFlags:1.2.840.113556.1.4.803:=2))" ` -Props @('dnsRoot','ncName','nETBIOSName') foreach ($cr in $crSearcher.FindAll()) { $nc = [string]$cr.Properties['ncname'][0] $dns = [string]$cr.Properties['dnsroot'][0] # Find a DC in this domain by querying its PDC FSMO $pdcDN = Get-ADOKLdapAttr -Server $DomainController -DN $nc -Attr 'fSMORoleOwner' $pdcName = Get-ADOKDcNameFromNtdsDN $pdcDN if (-not $pdcName) { $pdcName = $DomainController } $domainsToScan.Add(@{ DNSRoot = $dns; NC = $nc; PDC = $pdcName }) } } else { $pdcDN = Get-ADOKLdapAttr -Server $DomainController -DN $defaultNC -Attr 'fSMORoleOwner' $pdcName = Get-ADOKDcNameFromNtdsDN $pdcDN if (-not $pdcName) { $pdcName = $DomainController } $domainsToScan.Add(@{ DNSRoot = (ConvertFrom-ADOKDistinguishedName $defaultNC); NC = $defaultNC; PDC = $pdcName }) } Write-ADOKOk ("Domains: " + ($domainsToScan | ForEach-Object { $_.DNSRoot } | Sort-Object) -join ', ') #endregion #region -- collect DC inventory via LDAP Sites container ---------------------- Write-ADOKStep "Collecting domain controller inventory via LDAP ..." $allDCs = [System.Collections.Generic.List[hashtable]]::new() foreach ($domain in $domainsToScan) { $queryDC = $domain.PDC # --- FSMO role holders (read fSMORoleOwner from 5 well-known objects) --- $fsmoMap = @{ PDCEmulator = Get-ADOKDcNameFromNtdsDN (Get-ADOKLdapAttr $queryDC $domain.NC 'fSMORoleOwner') RIDMaster = Get-ADOKDcNameFromNtdsDN (Get-ADOKLdapAttr $queryDC "CN=RID Manager`$,CN=System,$($domain.NC)" 'fSMORoleOwner') InfrastructureMaster = Get-ADOKDcNameFromNtdsDN (Get-ADOKLdapAttr $queryDC "CN=Infrastructure,$($domain.NC)" 'fSMORoleOwner') SchemaMaster = Get-ADOKDcNameFromNtdsDN (Get-ADOKLdapAttr $queryDC "CN=Schema,$configNC" 'fSMORoleOwner') DomainNamingMaster = Get-ADOKDcNameFromNtdsDN (Get-ADOKLdapAttr $queryDC "CN=Partitions,$configNC" 'fSMORoleOwner') } # --- Find all server objects inside CN=Sites (one per DC per site) --- $srvSearcher = New-ADOKLdapSearcher ` -Server $queryDC ` -BaseDN "CN=Sites,$configNC" ` -Filter "(objectClass=server)" ` -Props @('cn','dNSHostName','distinguishedName') foreach ($srvObj in $srvSearcher.FindAll()) { $name = [string]$srvObj.Properties['cn'][0] $fqdn = if ($srvObj.Properties['dnshostname'].Count -gt 0) { [string]$srvObj.Properties['dnshostname'][0] } else { '' } $dn = [string]$srvObj.Properties['distinguishedname'][0] # Extract site name: CN=<name>,CN=Servers,CN=<site>,CN=Sites,... $siteName = if ($dn -match 'CN=Servers,CN=([^,]+),CN=Sites') { $Matches[1] } else { 'Unknown' } # Confirm it has an NTDS Settings child (proves it is an AD DC) $ntdsDN = "CN=NTDS Settings,$dn" try { $ntdsEntry = [System.DirectoryServices.DirectoryEntry]::new("LDAP://$queryDC/$ntdsDN") $ntdsEntry.RefreshCache([string[]]@('options','msDS-isRODC')) } catch { continue # no NTDS Settings = not a DC } # GC: bit 0 of the options attribute on NTDS Settings $optVal = 0 if ($ntdsEntry.Properties['options'].Count -gt 0) { $optVal = [int]$ntdsEntry.Properties['options'][0] } $isGC = ($optVal -band 1) -eq 1 # RODC: msDS-isRODC on NTDS Settings $isRODC = $false if ($ntdsEntry.Properties['msDS-isRODC'].Count -gt 0) { $isRODC = [bool]$ntdsEntry.Properties['msDS-isRODC'][0] } # Resolve FQDN via DNS if missing if (-not $fqdn) { try { $fqdn = [System.Net.Dns]::GetHostEntry($name).HostName } catch { $fqdn = $name } } # IPv4 via DNS $ipv4 = '(unknown)' try { $addrs = [System.Net.Dns]::GetHostAddresses($fqdn) | Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork } if ($addrs) { $ipv4 = $addrs[0].IPAddressToString } } catch { <# DNS lookup failed — IP stays empty #> } # OS version from computer object in domain NC $os = 'Unknown' try { $compSearch = New-ADOKLdapSearcher ` -Server $queryDC ` -BaseDN $domain.NC ` -Filter "(&(objectCategory=computer)(cn=$name))" ` -Props @('operatingSystem') $compResult = $compSearch.FindOne() if ($compResult -and $compResult.Properties['operatingsystem'].Count -gt 0) { $os = [string]$compResult.Properties['operatingsystem'][0] } } catch { <# LDAP query failed — OS stays Unknown #> } # FSMO roles this DC holds $fsmoRoles = @() foreach ($role in $fsmoMap.Keys) { if ($fsmoMap[$role] -eq $name) { $fsmoRoles += $role } } $allDCs.Add(@{ HostName = $fqdn Name = $name IPv4 = $ipv4 Site = $siteName OS = $os Domain = $domain.DNSRoot IsGC = $isGC IsRODC = $isRODC FsmoRoles = $fsmoRoles ReplFailures = 0 }) } } Write-ADOKOk "Found $($allDCs.Count) domain controller(s)" #endregion #region -- build DC name index (short name and FQDN -> HostName) -------------- $dcNameIndex = @{} foreach ($dc in $allDCs) { $dcNameIndex[$dc.Name.ToUpper()] = $dc.HostName $dcNameIndex[$dc.HostName.ToUpper()] = $dc.HostName } #endregion #region -- collect replication partnerships via repadmin ---------------------- Write-ADOKStep "Collecting replication topology via repadmin ..." $replEdges = [System.Collections.Generic.List[hashtable]]::new() try { # repadmin /showrepl * /csv queries every DC in the domain via DRSR/RPC # Output columns: Destination DSA Site, Destination DSA, Naming Context, # Source DSA Site, Source DSA, Transport Type, # Number of Failures, Last Failure Time, Last Success Time, Last Failure Status $rawCsv = & repadmin.exe /showrepl * /csv 2>$null $replCSV = $rawCsv | ConvertFrom-Csv foreach ($row in $replCSV) { $destName = ($row.'Destination DSA').Trim() $srcName = ($row.'Source DSA').Trim() if (-not $destName -or -not $srcName) { continue } $destFQDN = if ($dcNameIndex.ContainsKey($destName.ToUpper())) { $dcNameIndex[$destName.ToUpper()] } else { $destName } $srcFQDN = if ($dcNameIndex.ContainsKey($srcName.ToUpper())) { $dcNameIndex[$srcName.ToUpper()] } else { $srcName } $failures = 0 if ($row.'Number of Failures') { $failures = [int]($row.'Number of Failures') } $lastSuccess = $null if ($row.'Last Success Time' -and $row.'Last Success Time' -ne '') { try { $lastSuccess = [datetime]$row.'Last Success Time' } catch { <# unparseable date #> } } $lastAttempt = $null if ($row.'Last Failure Time' -and $row.'Last Failure Time' -ne '') { try { $lastAttempt = [datetime]$row.'Last Failure Time' } catch { <# unparseable date #> } } if (-not $lastAttempt -and $lastSuccess) { $lastAttempt = $lastSuccess } $replEdges.Add(@{ Source = $destFQDN Partner = $srcName PartnerFQDN = $srcFQDN Partition = $row.'Naming Context' LastAttempt = $lastAttempt LastSuccess = $lastSuccess ConsecFailures = $failures }) # Accumulate failure counts onto the destination DC if ($failures -gt 0) { $destDC = $allDCs | Where-Object { $_.HostName -eq $destFQDN -or $_.Name -eq $destName } | Select-Object -First 1 if ($destDC) { $destDC.ReplFailures += $failures } } } Write-ADOKOk "Found $($replEdges.Count) replication link(s)" } catch { Write-ADOKWarn "repadmin /showrepl failed: $_" } #endregion #region -- collect site links via LDAP ---------------------------------------- Write-ADOKStep "Collecting site links via LDAP ..." $siteLinks = [System.Collections.Generic.List[hashtable]]::new() try { $slSearcher = New-ADOKLdapSearcher ` -Server $DomainController ` -BaseDN "CN=Inter-Site Transports,CN=Sites,$configNC" ` -Filter "(objectClass=siteLink)" ` -Props @('cn','cost','replInterval','siteList') foreach ($sl in $slSearcher.FindAll()) { $slName = [string]$sl.Properties['cn'][0] $cost = if ($sl.Properties['cost'].Count -gt 0) { [int]$sl.Properties['cost'][0] } else { 100 } $freq = if ($sl.Properties['replinterval'].Count -gt 0) { [int]$sl.Properties['replinterval'][0] } else { 180 } # siteList contains DNs of sites; extract just the site name (CN=<site>) $siteNames = @() foreach ($siteDN in $sl.Properties['sitelist']) { if ($siteDN -match '^CN=([^,]+)') { $siteNames += $Matches[1] } } $siteLinks.Add(@{ Name = $slName Cost = $cost Frequency = $freq Sites = $siteNames }) } Write-ADOKOk "Found $($siteLinks.Count) site link(s)" } catch { Write-ADOKWarn "Site link collection failed: $_" } #endregion #region -- group DCs by site for diagram -------------------------------------- $siteGroups = @{} foreach ($dc in $allDCs) { if (-not $siteGroups.ContainsKey($dc.Site)) { $siteGroups[$dc.Site] = @() } $siteGroups[$dc.Site] += $dc } #endregion #region -- build SVG topology diagram (circular / star layout) ---------------- Write-ADOKStep "Building SVG replication topology diagram ..." # --- DC box dimensions --- $dcW = 210 $dcH = 68 # slightly taller to fit site label inside the box $margin = 60 # ----------------------------------------------------------------------- # Identify the PDC emulator - it goes in the centre of the diagram. # Fall back to the first DC if no PDC role is found. # ----------------------------------------------------------------------- $pdcDC = $allDCs | Where-Object { $_.FsmoRoles -contains 'PDCEmulator' } | Select-Object -First 1 if (-not $pdcDC) { $pdcDC = $allDCs | Select-Object -First 1 } $otherDCs = @($allDCs | Where-Object { $_.HostName -ne $pdcDC.HostName } | Sort-Object { $_.Site }, { $_.Name }) # ----------------------------------------------------------------------- # Calculate the orbit radius so the outer DC boxes never overlap each other # or the centre box. # Minimum spacing between adjacent outer boxes = dcW + 30 px gap. # Circumference needed = n * (dcW + 30). radius = circ / (2*pi) # ----------------------------------------------------------------------- $n = $otherDCs.Count if ($n -gt 0) { $minRadius = [int]([Math]::Max(260, ($n * ($dcW + 40)) / (2 * [Math]::PI))) } else { $minRadius = 0 } # Canvas: the centre point sits at (cx, cy); we need room on all four sides # for the outer boxes plus a margin. $cx = $margin + $minRadius + [int]($dcW / 2) $cy = $margin + $minRadius + [int]($dcH / 2) $canvasW = $cx * 2 $legendY = $cy * 2 + $margin $canvasH = $legendY + 70 # ----------------------------------------------------------------------- # Compute pixel positions # ----------------------------------------------------------------------- $dcPos = @{} # PDC at the centre $dcPos[$pdcDC.HostName] = @{ X1 = $cx - [int]($dcW / 2) Y1 = $cy - [int]($dcH / 2) CX = $cx ; CY = $cy DC = $pdcDC } # Outer DCs equally spaced around the orbit, starting at the top (-90 deg) for ($i = 0; $i -lt $n; $i++) { $angleDeg = -90 + ($i * 360 / $n) $angleRad = $angleDeg * [Math]::PI / 180 $ocx = [int]($cx + $minRadius * [Math]::Cos($angleRad)) $ocy = [int]($cy + $minRadius * [Math]::Sin($angleRad)) $dc = $otherDCs[$i] $dcPos[$dc.HostName] = @{ X1 = $ocx - [int]($dcW / 2) Y1 = $ocy - [int]($dcH / 2) CX = $ocx ; CY = $ocy DC = $dc } } # ----------------------------------------------------------------------- # Collect site colour palette (cycle through a set of distinct hues) # ----------------------------------------------------------------------- $sitePalette = @( @{ Fill='#0c2a4a'; Stroke='#1d6fa4' }, # blue @{ Fill='#1a2e1a'; Stroke='#3a8a3a' }, # green @{ Fill='#2e1a2e'; Stroke='#9a3a9a' }, # purple @{ Fill='#2e2200'; Stroke='#b87a00' }, # amber @{ Fill='#002e2e'; Stroke='#009a9a' }, # teal @{ Fill='#2e0a00'; Stroke='#c04000' } # orange ) $siteColorMap = @{} $paletteIdx = 0 foreach ($site in ($allDCs | ForEach-Object { $_.Site } | Select-Object -Unique | Sort-Object)) { $siteColorMap[$site] = $sitePalette[$paletteIdx % $sitePalette.Count] $paletteIdx++ } # ----------------------------------------------------------------------- # Build SVG # ----------------------------------------------------------------------- $svg = [System.Text.StringBuilder]::new() $null = $svg.AppendLine("<svg xmlns='http://www.w3.org/2000/svg' width='$canvasW' height='$canvasH' font-family='Segoe UI,Arial,sans-serif'>") $null = $svg.AppendLine(@" <defs> <marker id='arr' markerWidth='10' markerHeight='7' refX='9' refY='3.5' orient='auto'> <polygon points='0 0, 10 3.5, 0 7' fill='#38bdf8'/> </marker> <marker id='arrFail' markerWidth='10' markerHeight='7' refX='9' refY='3.5' orient='auto'> <polygon points='0 0, 10 3.5, 0 7' fill='#f87171'/> </marker> <filter id='glow'> <feGaussianBlur stdDeviation='3' result='blur'/> <feMerge><feMergeNode in='blur'/><feMergeNode in='SourceGraphic'/></feMerge> </filter> <filter id='shadow' x='-15%' y='-15%' width='130%' height='130%'> <feDropShadow dx='2' dy='3' stdDeviation='4' flood-color='#000000aa'/> </filter> </defs> "@) # Background $null = $svg.AppendLine("<rect width='$canvasW' height='$canvasH' fill='#0a0f1e'/>") # Subtle orbit ring (visual guide) if ($n -gt 0) { $null = $svg.AppendLine("<circle cx='$cx' cy='$cy' r='$minRadius' fill='none' stroke='#1e293b' stroke-width='1' stroke-dasharray='6,6'/>") } # ----------------------------------------------------------------------- # Deduplicate replication edges (one per directed DC pair, worst failures) # ----------------------------------------------------------------------- $uniqueEdgeMap = @{} foreach ($edge in $replEdges) { $srcH = $edge.Source ; $dstH = $edge.PartnerFQDN if (-not $srcH -or -not $dstH) { continue } $key = "$srcH||$dstH" if (-not $uniqueEdgeMap.ContainsKey($key)) { $uniqueEdgeMap[$key] = @{} + $edge } else { if ($edge.ConsecFailures -gt $uniqueEdgeMap[$key].ConsecFailures) { $uniqueEdgeMap[$key].ConsecFailures = $edge.ConsecFailures $uniqueEdgeMap[$key].LastAttempt = $edge.LastAttempt } } } # ----------------------------------------------------------------------- # Draw replication arrows FIRST (so they appear behind the DC boxes) # ----------------------------------------------------------------------- $drawnPairs = [System.Collections.Generic.HashSet[string]]::new() foreach ($edge in $uniqueEdgeMap.Values) { $srcH = $edge.Source ; $dstH = $edge.PartnerFQDN if (-not $dcPos.ContainsKey($srcH) -or -not $dcPos.ContainsKey($dstH)) { continue } $pairKey = (($srcH, $dstH | Sort-Object) -join '|') $isReturn = $drawnPairs.Contains($pairKey) # true = second direction of a bidirectional pair $null = $drawnPairs.Add($pairKey) $src = $dcPos[$srcH] ; $dst = $dcPos[$dstH] $hasFail = $edge.ConsecFailures -gt 0 $arrowId = if ($hasFail) { 'arrFail' } else { 'arr' } $lineColor = if ($hasFail) { '#f87171' } else { '#38bdf8' } $dashStyle = if ($hasFail) { "stroke-dasharray='6,4'" } else { '' } $ax1 = [int]$src.CX ; $ay1 = [int]$src.CY $ax2 = [int]$dst.CX ; $ay2 = [int]$dst.CY # Offset the two directions of a bidirectional pair to avoid overlap. # Bow size scales with distance; flip perpendicular side for the return arrow. $mdx = $ax2 - $ax1 ; $mdy = $ay2 - $ay1 $mlen = [Math]::Sqrt($mdx*$mdx + $mdy*$mdy) ; if ($mlen -lt 1) { $mlen = 1 } $bow = [Math]::Max(55, $mlen * 0.38) $side = if ($isReturn) { 1 } else { -1 } $mcpx = [int](($ax1+$ax2)/2 - $mdy/$mlen * $bow * $side) $mcpy = [int](($ay1+$ay2)/2 + $mdx/$mlen * $bow * $side) $null = $svg.AppendLine("<path d='M $ax1 $ay1 Q $mcpx $mcpy $ax2 $ay2' fill='none' stroke='$lineColor' stroke-width='2' $dashStyle marker-end='url(#$arrowId)' opacity='0.8'/>") } # ----------------------------------------------------------------------- # Draw DC boxes ON TOP of the arrows # ----------------------------------------------------------------------- foreach ($hn in $dcPos.Keys) { $p = $dcPos[$hn] $dc = $p.DC $x1 = $p.X1 ; $y1 = $p.Y1 $isPdc = ($dc.HostName -eq $pdcDC.HostName) $hasFail = $dc.ReplFailures -gt 0 $siteCol = $siteColorMap[$dc.Site] # Outer site-coloured glow ring around each box $null = $svg.AppendLine("<rect x='$($x1-4)' y='$($y1-4)' width='$($dcW+8)' height='$($dcH+8)' rx='10' fill='$($siteCol.Fill)' stroke='$($siteCol.Stroke)' stroke-width='2' opacity='0.6'/>") # Main DC box if ($isPdc) { # PDC: gold border, slightly larger shadow $fill = if ($hasFail) { '#4a1010' } else { '#1a1a3e' } $stroke = if ($hasFail) { '#f87171' } else { '#fbbf24' } $null = $svg.AppendLine("<rect x='$x1' y='$y1' width='$dcW' height='$dcH' rx='7' fill='$fill' stroke='$stroke' stroke-width='2.5' filter='url(#shadow)'/>") } else { $fill = if ($hasFail) { '#7f1d1d' } else { '#0f2044' } $stroke = if ($hasFail) { '#f87171' } else { '#38bdf8' } $null = $svg.AppendLine("<rect x='$x1' y='$y1' width='$dcW' height='$dcH' rx='7' fill='$fill' stroke='$stroke' stroke-width='1.5' filter='url(#shadow)'/>") } # Site name banner at top of box $siteEsc = ConvertTo-ADOKXmlEscaped $dc.Site $null = $svg.AppendLine("<text x='$($x1 + $dcW/2)' y='$($y1 + 13)' text-anchor='middle' font-size='9' fill='$($siteCol.Stroke)' letter-spacing='0.5'>$siteEsc</text>") # Thin separator line under site label $null = $svg.AppendLine("<line x1='$($x1+8)' y1='$($y1+17)' x2='$($x1+$dcW-8)' y2='$($y1+17)' stroke='$($siteCol.Stroke)' stroke-width='0.5' opacity='0.5'/>") # Hostname $nameCol = if ($isPdc) { '#fde68a' } else { '#e2e8f0' } $nameEsc = ConvertTo-ADOKXmlEscaped $dc.Name $null = $svg.AppendLine("<text x='$($x1 + $dcW/2)' y='$($y1 + 31)' text-anchor='middle' font-size='12' font-weight='700' fill='$nameCol'>$nameEsc</text>") # IPv4 $ipEsc = ConvertTo-ADOKXmlEscaped $dc.IPv4 $null = $svg.AppendLine("<text x='$($x1 + $dcW/2)' y='$($y1 + 45)' text-anchor='middle' font-size='10' fill='#94a3b8'>$ipEsc</text>") # Role / badge line $badges = @() if ($isPdc) { $badges += 'PDC' } if ($dc.IsGC) { $badges += 'GC' } if ($dc.IsRODC) { $badges += 'RODC' } $extraRoles = $dc.FsmoRoles | Where-Object { $_ -ne 'PDCEmulator' } | ForEach-Object { ($_ -replace 'Master','').Trim() } if ($extraRoles) { $badges += $extraRoles } if ($dc.ReplFailures -gt 0) { $badges += "FAIL:$($dc.ReplFailures)" } if ($badges) { $badgeColor = if ($hasFail) { '#f87171' } elseif ($isPdc) { '#fbbf24' } else { '#38bdf8' } $badgeEsc = ConvertTo-ADOKXmlEscaped ($badges -join ' ') $null = $svg.AppendLine("<text x='$($x1 + $dcW/2)' y='$($y1 + 60)' text-anchor='middle' font-size='9' fill='$badgeColor' font-weight='600'>$badgeEsc</text>") } } # ----------------------------------------------------------------------- # Site legend pills (bottom strip) # ----------------------------------------------------------------------- $null = $svg.AppendLine("<text x='$margin' y='$($legendY + 4)' font-size='11' font-weight='700' fill='#475569'>Sites</text>") $pillX = $margin + 40 ; $pillY = $legendY - 6 foreach ($site in ($siteColorMap.Keys | Sort-Object)) { $col = $siteColorMap[$site] $sEsc = ConvertTo-ADOKXmlEscaped $site $tw = $sEsc.Length * 7 + 24 # approximate pill width $null = $svg.AppendLine("<rect x='$pillX' y='$pillY' width='$tw' height='18' rx='9' fill='$($col.Fill)' stroke='$($col.Stroke)' stroke-width='1.2'/>") $null = $svg.AppendLine("<text x='$($pillX + $tw/2)' y='$($pillY + 12)' text-anchor='middle' font-size='10' fill='$($col.Stroke)'>$sEsc</text>") $pillX += $tw + 10 } # Replication link legend $rx = $margin ; $ry = $legendY + 20 $null = $svg.AppendLine("<text x='$rx' y='$($ry + 4)' font-size='11' font-weight='700' fill='#475569'>Links</text>") $null = $svg.AppendLine("<line x1='$($rx+44)' y1='$($ry+2)' x2='$($rx+76)' y2='$($ry+2)' stroke='#38bdf8' stroke-width='2' marker-end='url(#arr)'/>") $null = $svg.AppendLine("<text x='$($rx+82)' y='$($ry+6)' font-size='10' fill='#94a3b8'>Replication OK</text>") $null = $svg.AppendLine("<line x1='$($rx+220)' y1='$($ry+2)' x2='$($rx+252)' y2='$($ry+2)' stroke='#f87171' stroke-width='2' stroke-dasharray='5,3' marker-end='url(#arrFail)'/>") $null = $svg.AppendLine("<text x='$($rx+258)' y='$($ry+6)' font-size='10' fill='#94a3b8'>Replication FAIL</text>") # PDC marker $null = $svg.AppendLine("<rect x='$($rx+400)' y='$($ry-6)' width='14' height='14' rx='3' fill='#1a1a3e' stroke='#fbbf24' stroke-width='2'/>") $null = $svg.AppendLine("<text x='$($rx+420)' y='$($ry+6)' font-size='10' fill='#94a3b8'>PDC Emulator (centre)</text>") $null = $svg.AppendLine("</svg>") $svgDiagram = $svg.ToString() #endregion #region -- build site link table rows ----------------------------------------- $siteLinkRows = foreach ($sl in $siteLinks) { $sites = $sl.Sites -join ' <-> ' "<tr><td>$($sl.Name)</td><td>$sites</td><td>$($sl.Cost)</td><td>$($sl.Frequency) min</td></tr>" } #endregion #region -- build DC detail table rows ----------------------------------------- $dcRows = foreach ($dc in ($allDCs | Sort-Object { $_.Site }, { $_.HostName })) { $roles = if ($dc.FsmoRoles) { $dc.FsmoRoles -join '<br>' } else { '-' } $flags = @() if ($dc.IsGC) { $flags += 'Global Catalog' } if ($dc.IsRODC) { $flags += 'RODC' } $flagStr = if ($flags) { $flags -join ', ' } else { '-' } $failClass = if ($dc.ReplFailures -gt 0) { ' class="fail"' } else { '' } "<tr$failClass><td>$($dc.HostName)</td><td>$($dc.IPv4)</td><td>$($dc.Site)</td><td>$($dc.Domain)</td><td>$($dc.OS)</td><td>$flagStr</td><td>$roles</td><td>$($dc.ReplFailures)</td></tr>" } #endregion #region -- build replication edge table rows ---------------------------------- $edgeRows = foreach ($edge in ($replEdges | Sort-Object Source)) { $lastOk = if ($edge.LastSuccess) { $edge.LastSuccess.ToString('yyyy-MM-dd HH:mm') } else { '-' } $lastTry = if ($edge.LastAttempt) { $edge.LastAttempt.ToString('yyyy-MM-dd HH:mm') } else { '-' } $failCls = if ($edge.ConsecFailures -gt 0) { ' class="fail"' } else { '' } "<tr$failCls><td>$($edge.Source)</td><td>$($edge.PartnerFQDN)</td><td>$($edge.Partition)</td><td>$lastTry</td><td>$lastOk</td><td>$($edge.ConsecFailures)</td></tr>" } #endregion #region -- assemble HTML ------------------------------------------------------ $generatedAt = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $forestName = $forestDNS $dcRowsHtml = $dcRows -join "`n " $edgeRowsHtml = $edgeRows -join "`n " $siteLinkRowsHtml = $siteLinkRows -join "`n " $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>AD Replication Topology - $forestName</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: Segoe UI, Arial, sans-serif; background: #0f172a; color: #e2e8f0; } header { background: #1e293b; padding: 20px 32px; border-bottom: 1px solid #334155; } header h1 { font-size: 1.5rem; font-weight: 700; color: #38bdf8; } header p { font-size: .85rem; color: #94a3b8; margin-top: 4px; } nav { display: flex; gap: 8px; padding: 14px 32px; background: #1e293b; border-bottom: 1px solid #334155; flex-wrap: wrap; } nav a { color: #38bdf8; font-size: .83rem; text-decoration: none; padding: 4px 12px; border: 1px solid #334155; border-radius: 4px; } nav a:hover { background: #334155; } section { padding: 28px 32px; } section h2 { font-size: 1.1rem; font-weight: 600; color: #7dd3fc; margin-bottom: 16px; border-left: 3px solid #38bdf8; padding-left: 10px; } .diagram-wrap { overflow-x: auto; background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 12px; } table { width: 100%; border-collapse: collapse; font-size: .82rem; } th { background: #1e3a5f; color: #93c5fd; padding: 8px 10px; text-align: left; white-space: nowrap; } td { padding: 7px 10px; border-bottom: 1px solid #1e293b; vertical-align: top; } tr:nth-child(even) td { background: #0f1f35; } tr.fail td { background: #3b1010 !important; color: #fca5a5; } footer { text-align: center; padding: 20px; font-size: .75rem; color: #475569; border-top: 1px solid #1e293b; } </style> </head> <body> <header> <h1>Active Directory Replication Topology</h1> <p>Forest: <strong>$forestName</strong> | Generated: $generatedAt | Source DC: $DomainController</p> </header> <nav> <a href="#diagram">Topology Diagram</a> <a href="#dcs">Domain Controllers</a> <a href="#replication">Replication Links</a> <a href="#sitelinks">Site Links</a> </nav> <section id="diagram"> <h2>Replication Topology Diagram</h2> <p style="font-size:.8rem;color:#64748b;margin-bottom:14px;"> Each box is a domain controller grouped by AD site. Arrows show replication flow (curved = bidirectional where both DCs replicate from each other). Red boxes = consecutive replication failures. Dashed red arrows = failing replication links. </p> <div class="diagram-wrap"> $svgDiagram </div> </section> <section id="dcs"> <h2>Domain Controllers ($($allDCs.Count))</h2> <table> <thead> <tr> <th>Hostname</th><th>IPv4</th><th>Site</th><th>Domain</th> <th>OS</th><th>Flags</th><th>FSMO Roles</th><th>Repl Failures</th> </tr> </thead> <tbody> $dcRowsHtml </tbody> </table> </section> <section id="replication"> <h2>Replication Partnerships ($($replEdges.Count))</h2> <table> <thead> <tr> <th>DC (Inbound)</th><th>Partner (Source)</th><th>Partition</th> <th>Last Attempt</th><th>Last Success</th><th>Consec. Failures</th> </tr> </thead> <tbody> $edgeRowsHtml </tbody> </table> </section> <section id="sitelinks"> <h2>Site Links ($($siteLinks.Count))</h2> <table> <thead> <tr><th>Name</th><th>Sites</th><th>Cost</th><th>Frequency</th></tr> </thead> <tbody> $siteLinkRowsHtml </tbody> </table> </section> <footer>Generated by Get-ADReplicationTopologyDiagram — ADOpsKit | $generatedAt</footer> </body> </html> "@ #endregion #region -- write output ------------------------------------------------------- $outputDir = Split-Path $OutputPath if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } $outputFile = $OutputPath $html | Out-File -FilePath $outputFile -Encoding utf8 -Force Write-ADOKOk "Report written to: $outputFile" #endregion #region -- console summary ---------------------------------------------------- Write-Host "" Write-Host " +==========================================+" -ForegroundColor Cyan Write-Host " | AD Replication Topology - Summary |" -ForegroundColor Cyan Write-Host " +==========================================+" -ForegroundColor Cyan Write-Host " Forest : $forestName" Write-Host " Domains scanned : $($domainsToScan.Count)" Write-Host " Domain controllers: $($allDCs.Count)" Write-Host " Replication links : $($replEdges.Count)" Write-Host " Site links : $($siteLinks.Count)" $failingDCs = $allDCs | Where-Object { $_.ReplFailures -gt 0 } if ($failingDCs) { Write-Host "" Write-Host " DCs with replication failures:" -ForegroundColor Yellow foreach ($dc in $failingDCs) { Write-Host (" - {0} ({1} failure(s))" -f $dc.HostName, $dc.ReplFailures) -ForegroundColor Red } } Write-Host "" Write-Host " Output: $outputFile" -ForegroundColor Green #endregion } |