Public/Add-DhTopologyMap.ps1
|
function Add-DhTopologyMap { <# .SYNOPSIS Add a topology / hierarchy map block (tree visualisation, v1.5+). .DESCRIPTION Renders a parent-child tree from a flat list of nodes. Per the IT-infrastructure KPI dashboard specification §2 ("Topology / Hierarchy Map"): used for AD Forest → Domain → DC, vCenter → Cluster → Host, network device hierarchies, etc. The renderer is intentionally simple: a recursive SVG tree with "spread leaves evenly, centre parents above children" layout. No force-directed graphs, no Reingold-Tilford — just a clean tidy tree that handles realistic IT hierarchies (≲ 50 nodes well). Cycles are rejected; missing parents throw. .PARAMETER Report Dashboard object from New-DhDashboard. .PARAMETER Id Unique identifier (alphanumeric, dash, underscore). .PARAMETER Title Block heading shown above the tree. .PARAMETER Nodes Array of node hashtables: @{ Id = 'forest' # REQUIRED — unique within this topology Label = 'contoso.com' # REQUIRED — displayed inside the node Parent = 'root-id' # OR $null/'' for a root node Status = 'ok' # optional — ok | warn | danger | nodata | info Icon = 'F' # optional — emoji/glyph Badge = 'AD' # optional — small tag next to the label Tooltip = 'Forest functional level 2016' # optional LinkTableId = 'incidents' # optional — click navigates to a table LinkFilter = 'auth-eu' # optional — applied filter on jump } Multiple root nodes (Parent=$null) are allowed and render as forests. .PARAMETER Direction 'vertical' (default — parent above children, tree flows downward) or 'horizontal' (parent left of children, tree flows rightward). .PARAMETER NodeWidth / -NodeHeight Pixel dimensions of each node box. Defaults: 140 × 44. .PARAMETER MaxNodes Hard ceiling. Default 200. Throws above this. Trees larger than this usually need a different visualisation anyway. .PARAMETER NavGroup Primary nav group label. .PARAMETER NavSubGroup Optional second-level group. .EXAMPLE Add-DhTopologyMap -Report $report -Id 'ad-forest' -Title 'AD Forest' ` -Nodes @( @{ Id='forest'; Label='contoso.com'; Status='ok'; Parent=$null } @{ Id='dom-hq'; Label='hq.contoso.com'; Status='ok'; Parent='forest' } @{ Id='dom-eu'; Label='eu.contoso.com'; Status='warn'; Parent='forest' } @{ Id='dc01'; Label='DC01'; Status='ok'; Parent='dom-hq' } @{ Id='dc02'; Label='DC02'; Status='ok'; Parent='dom-hq' } @{ Id='dc03'; Label='DC03'; Status='warn'; Parent='dom-eu' Tooltip='Replication lag 4m'; LinkTableId='incidents'; LinkFilter='dc03' } @{ Id='dc04'; Label='DC04'; Status='danger'; Parent='dom-eu' } ) #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report, [Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9_-]+$')] [string] $Id, [Parameter(Mandatory)] [string] $Title, [Parameter(Mandatory)] [object[]] $Nodes, [ValidateSet('vertical','horizontal')] [string] $Direction = 'vertical', [ValidateRange(60, 400)] [int] $NodeWidth = 140, [ValidateRange(24, 200)] [int] $NodeHeight = 44, [ValidateRange(1, 1000)] [int] $MaxNodes = 200, [string] $NavGroup = '', [string] $NavSubGroup = '' ) if (-not $Report.Contains('Blocks')) { $Report['Blocks'] = [System.Collections.Generic.List[hashtable]]::new() } foreach ($existing in $Report.Blocks) { if ($existing.Id -eq $Id) { throw "Add-DhTopologyMap: A block with Id '$Id' already exists in this report." } } if ($Nodes.Count -lt 1) { throw "Add-DhTopologyMap: -Nodes must contain at least one node." } if ($Nodes.Count -gt $MaxNodes) { throw "Add-DhTopologyMap: $($Nodes.Count) nodes exceeds -MaxNodes ($MaxNodes). Tighten the scope or raise -MaxNodes deliberately." } $allowedStatus = @('ok','warn','danger','nodata','info','') # First pass: normalise + collect ids + detect duplicates $idSet = @{} $normNodes = foreach ($n in $Nodes) { if ($n -isnot [hashtable] -and $n -isnot [System.Collections.Specialized.OrderedDictionary]) { throw "Add-DhTopologyMap: each node must be a hashtable. Got: $($n.GetType().Name)" } foreach ($req in 'Id','Label') { if (-not $n.Contains($req) -or [string]::IsNullOrWhiteSpace([string]$n[$req])) { throw "Add-DhTopologyMap: each node must have a non-empty '$req' key." } } $nid = [string]$n['Id'] if ($idSet.ContainsKey($nid)) { throw "Add-DhTopologyMap: duplicate node Id '$nid'." } $idSet[$nid] = $true $status = if ($n.Contains('Status') -and $n['Status']) { ([string]$n['Status']).ToLowerInvariant() } else { '' } if ($status -notin $allowedStatus) { throw "Add-DhTopologyMap: node '$nid' — Status must be 'ok'/'warn'/'danger'/'nodata'/'info' or empty. Got: $($n['Status'])" } $parent = if ($n.Contains('Parent') -and -not [string]::IsNullOrWhiteSpace([string]$n['Parent'])) { [string]$n['Parent'] } else { '' } # Action validation $linkTableId = if ($n.Contains('LinkTableId') -and -not [string]::IsNullOrWhiteSpace([string]$n['LinkTableId'])) { [string]$n['LinkTableId'] } else { '' } $linkFilter = if ($n.Contains('LinkFilter') -and -not [string]::IsNullOrWhiteSpace([string]$n['LinkFilter'])) { [string]$n['LinkFilter'] } else { '' } if ($linkFilter -and -not $linkTableId) { throw "Add-DhTopologyMap: node '$nid' — LinkFilter requires LinkTableId." } @{ Id = $nid Label = [string]$n['Label'] Parent = $parent Status = $status Icon = if ($n.Contains('Icon') -and $null -ne $n['Icon']) { [string]$n['Icon'] } else { '' } Badge = if ($n.Contains('Badge') -and $null -ne $n['Badge']) { [string]$n['Badge'] } else { '' } Tooltip = if ($n.Contains('Tooltip') -and $null -ne $n['Tooltip']) { [string]$n['Tooltip'] } else { '' } LinkTableId = $linkTableId LinkFilter = $linkFilter } } # Second pass: verify parents exist and there are no cycles foreach ($n in $normNodes) { if ($n.Parent -and -not $idSet.ContainsKey($n.Parent)) { throw "Add-DhTopologyMap: node '$($n.Id)' references unknown Parent '$($n.Parent)'." } if ($n.Id -eq $n.Parent) { throw "Add-DhTopologyMap: node '$($n.Id)' cannot be its own Parent." } } # Cycle detection: walk up from each node, bail if we revisit foreach ($n in $normNodes) { $seen = @{ ($n.Id) = $true } $cur = $n.Parent $depth = 0 while ($cur -and $depth -lt 1000) { if ($seen.ContainsKey($cur)) { throw "Add-DhTopologyMap: cycle detected at node '$cur' (in chain starting from '$($n.Id)')." } $seen[$cur] = $true $cur = ($normNodes | Where-Object { $_.Id -eq $cur } | Select-Object -First 1).Parent $depth++ } } $Report.Blocks.Add([ordered]@{ BlockType = 'topologymap' Id = $Id Title = $Title Nodes = @($normNodes) Direction = $Direction NodeWidth = $NodeWidth NodeHeight = $NodeHeight NavGroup = $NavGroup NavSubGroup = $NavSubGroup }) $rootCount = @($normNodes | Where-Object { -not $_.Parent }).Count Write-Verbose "Add-DhTopologyMap: '$Id' ($($normNodes.Count) nodes, $rootCount root(s), direction=$Direction)." } |