Public/Add-DhNetworkMap.ps1
|
function Add-DhNetworkMap { <# .SYNOPSIS Add a network / dependency map: nodes joined by edges, in groups or in tier lanes. .DESCRIPTION v2.4.0. Draws a graph that is not a tree: domain controllers and their replication links, sites and site links, trusts, or accounts and the tier they can reach. Unlike Add-DhTopologyMap (one parent per node) a node can have any number of links, several links can join the same two nodes, and a link can have a direction, a label, a status colour and a dashed style. The layout is computed in the browser and is deterministic: the same data always gives the same picture (no force simulation). -Layout grouped nodes sit in boxes (Groups), boxes can nest. For sites > domains > servers. -Layout layered horizontal lanes by Tier (0 on top). For Tier 0/1/2 views. A node without Tier takes its depth from the links (From above To). The reader can pan and zoom, filter by status or badge, search nodes, click a node to highlight its links (and open its Detail in the side panel, or jump to a table), trace the shortest path from a node to the nearest Target node, and download the map as SVG. All text is HTML-encoded. .PARAMETER Report Dashboard object from New-DhDashboard. .PARAMETER Id Unique block identifier (alphanumeric, - or _). .PARAMETER Title Heading shown above the map. .PARAMETER Nodes Array of hashtables: @{ Id='dc01'; Label='DC01'; Group='site-hq'; Tier=0; Status='ok' Badge=@('PDC','GC'); Target=$false; Icon=''; Tooltip='...' Detail=<text or sections>; LinkTableId='dcs'; LinkFilter='DC01' } Id and Label are required. Status is ok | warn | danger | info | nodata. Badge is a list of short tags (shown on the node, usable as a filter). Target marks the nodes "trace path" aims for (for example Tier 0 assets). .PARAMETER Edges Array of hashtables: @{ From='dc01'; To='dc02'; Label='15 min'; Status='danger' Style='dashed'; Direction='both'; Tooltip='Last failure 02:10' LinkTableId='repl'; LinkFilter='dc01' } From and To must be node Ids. Direction is none | forward (default) | both. Style is solid (default) | dashed. Several edges between the same two nodes are drawn as parallel curves. .PARAMETER Groups Array of @{ Id; Label; Parent; Status } boxes for -Layout grouped. Parent nests a group inside another. .PARAMETER Layout grouped (default) or layered. .PARAMETER MaxNodes Hard ceiling (default 300). .PARAMETER MaxEdges Hard ceiling (default 1500). .PARAMETER NodeWidth Node box width in px (default 150). .PARAMETER NodeHeight Node box height in px (default 46). .PARAMETER Height Viewport height in px (default 520). .PARAMETER Legend Show a legend of the colours and line styles. .EXAMPLE Add-DhNetworkMap -Report $r -Id 'repl' -Title 'Replication' -Legend ` -Groups @(@{ Id='hq'; Label='HQ' }, @{ Id='eu'; Label='EU' }) ` -Nodes @( @{ Id='dc01'; Label='DC01'; Group='hq'; Badge=@('PDC') } @{ Id='dc02'; Label='DC02'; Group='eu'; Status='warn' } ) ` -Edges @( @{ From='dc01'; To='dc02'; Label='15 min'; Direction='both' } ) #> [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, [AllowEmptyCollection()] [object[]] $Edges = @(), [AllowEmptyCollection()] [object[]] $Groups = @(), [ValidateSet('grouped','layered')] [string] $Layout = 'grouped', [ValidateRange(1, 2000)] [int] $MaxNodes = 300, [ValidateRange(1, 20000)] [int] $MaxEdges = 1500, [ValidateRange(60, 400)] [int] $NodeWidth = 150, [ValidateRange(24, 200)] [int] $NodeHeight = 46, [ValidateRange(200, 1600)] [int] $Height = 520, [switch] $Legend, [bool] $Collapsible = $false, [bool] $DefaultOpen = $true, [ValidateRange(0,12)] [int] $GridSpan = 0, [string] $NavGroup = '', [string] $NavSubGroup = '', # ---- v2.0.0 navigation (see readme-dev.md section 5) ---- [string] $NavPath = '', [ValidateSet('','Exact','Subtree','Global')] [string] $NavScope = '', [string] $NavLabel = '', [int] $NavOrder = 0 ) $navDecl = Resolve-DhNavDeclaration -NavPath $NavPath -NavScope $NavScope ` -NavGroup $NavGroup -NavSubGroup $NavSubGroup ` -ItemKind 'Block' -Context 'Add-DhNetworkMap' if (-not $Report.Contains('_DhNavSeq')) { $Report['_DhNavSeq'] = 0 } $navSeqNo = [int]$Report['_DhNavSeq']; $Report['_DhNavSeq'] = $navSeqNo + 1 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-DhNetworkMap: A block with Id '$Id' already exists in this report. Use a unique Id." } } if (@($Nodes).Count -lt 1) { throw "Add-DhNetworkMap '$Id': -Nodes must contain at least one node." } if (@($Nodes).Count -gt $MaxNodes) { throw "Add-DhNetworkMap '$Id': $(@($Nodes).Count) nodes exceeds -MaxNodes ($MaxNodes). Tighten the scope or raise -MaxNodes deliberately." } if (@($Edges).Count -gt $MaxEdges) { throw "Add-DhNetworkMap '$Id': $(@($Edges).Count) edges exceeds -MaxEdges ($MaxEdges). Tighten the scope or raise -MaxEdges deliberately." } $allowedStatus = @('ok','warn','danger','info','nodata','') $statusOf = { param($raw, $where) $s = ConvertTo-DhStatusName $raw $s = switch ($s) { 'warning' { 'warn' } 'critical' { 'danger' } 'error' { 'danger' } default { $s } } if ($s -notin $allowedStatus) { throw "Add-DhNetworkMap '$Id': $where Status must be ok, warn, danger, info or nodata. Got: $raw" } $s } $str = { param($h, $k) if ($h.Contains($k) -and $null -ne $h[$k]) { [string]$h[$k] } else { '' } } # ---- groups ------------------------------------------------------------ $groupIds = @{} $normGroups = @(foreach ($g in @($Groups)) { if ($null -eq $g) { continue } if ($g -isnot [System.Collections.IDictionary]) { throw "Add-DhNetworkMap '$Id': each group must be a hashtable. Got: $($g.GetType().Name)" } foreach ($req in 'Id','Label') { if (-not $g.Contains($req) -or [string]::IsNullOrWhiteSpace([string]$g[$req])) { throw "Add-DhNetworkMap '$Id': each group must have a non-empty '$req' key." } } $gid = [string]$g['Id'] if ($groupIds.ContainsKey($gid)) { throw "Add-DhNetworkMap '$Id': duplicate group Id '$gid'." } $groupIds[$gid] = $true [ordered]@{ Id = $gid Label = [string]$g['Label'] Parent = (& $str $g 'Parent').Trim() Status = & $statusOf $(if ($g.Contains('Status')) { $g['Status'] } else { '' }) "group '$gid'" } }) $groupById = @{} foreach ($g in $normGroups) { $groupById[$g.Id] = $g } foreach ($g in $normGroups) { if ($g.Parent -and -not $groupById.ContainsKey($g.Parent)) { throw "Add-DhNetworkMap '$Id': group '$($g.Id)' references unknown Parent '$($g.Parent)'." } if ($g.Parent -eq $g.Id) { throw "Add-DhNetworkMap '$Id': group '$($g.Id)' cannot be its own Parent." } } foreach ($g in $normGroups) { $seen = @{ ($g.Id) = $true }; $cur = $g.Parent; $d = 0 while ($cur -and $d -lt 10000) { if ($seen.ContainsKey($cur)) { throw "Add-DhNetworkMap '$Id': group cycle detected at '$cur' (in chain starting from '$($g.Id)')." } $seen[$cur] = $true; $cur = $groupById[$cur].Parent; $d++ } } # ---- nodes ------------------------------------------------------------- $nodeIds = @{} $normNodes = @(foreach ($n in $Nodes) { if ($n -isnot [System.Collections.IDictionary]) { throw "Add-DhNetworkMap '$Id': 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-DhNetworkMap '$Id': each node must have a non-empty '$req' key." } } $nid = [string]$n['Id'] if ($nodeIds.ContainsKey($nid)) { throw "Add-DhNetworkMap '$Id': duplicate node Id '$nid'." } $nodeIds[$nid] = $true $grp = (& $str $n 'Group').Trim() if ($grp -and -not $groupById.ContainsKey($grp)) { throw "Add-DhNetworkMap '$Id': node '$nid' references unknown Group '$grp'." } $tier = $null if ($n.Contains('Tier') -and $null -ne $n['Tier'] -and "$($n['Tier'])" -ne '') { $t = 0 if (-not [int]::TryParse([string]$n['Tier'], [ref]$t) -or $t -lt 0) { throw "Add-DhNetworkMap '$Id': node '$nid' Tier must be a whole number >= 0. Got: $($n['Tier'])" } $tier = $t } $lt = (& $str $n 'LinkTableId').Trim(); $lf = (& $str $n 'LinkFilter') if ($lf -and -not $lt) { throw "Add-DhNetworkMap '$Id': node '$nid' - LinkFilter requires LinkTableId." } $badges = @(if ($n.Contains('Badge') -and $null -ne $n['Badge']) { @($n['Badge']) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | ForEach-Object { [string]$_ } }) [ordered]@{ Id = $nid Label = [string]$n['Label'] Group = $grp Tier = $tier Status = & $statusOf $(if ($n.Contains('Status')) { $n['Status'] } else { '' }) "node '$nid'" Icon = & $str $n 'Icon' Badge = $badges Target = [bool]($n.Contains('Target') -and $n['Target']) Tooltip = & $str $n 'Tooltip' Detail = if ($n.Contains('Detail')) { ConvertTo-DhDetailSpec -Detail $n['Detail'] -Context "Add-DhNetworkMap '$Id' node '$nid'" } else { $null } LinkTableId = $lt LinkFilter = $lf } }) # ---- edges ------------------------------------------------------------- $n = 0 $normEdges = @(foreach ($e in @($Edges)) { if ($null -eq $e) { continue } $n++ if ($e -isnot [System.Collections.IDictionary]) { throw "Add-DhNetworkMap '$Id': each edge must be a hashtable. Got: $($e.GetType().Name)" } foreach ($req in 'From','To') { if (-not $e.Contains($req) -or [string]::IsNullOrWhiteSpace([string]$e[$req])) { throw "Add-DhNetworkMap '$Id': edge #$n needs a non-empty '$req'." } } $from = [string]$e['From']; $to = [string]$e['To'] foreach ($end in @(@('From', $from), @('To', $to))) { if (-not $nodeIds.ContainsKey($end[1])) { throw "Add-DhNetworkMap '$Id': edge #$n $($end[0]) '$($end[1])' is not a node Id." } } $dir = if ($e.Contains('Direction') -and $e['Direction']) { ([string]$e['Direction']).ToLowerInvariant() } else { 'forward' } if ($dir -notin 'none','forward','both') { throw "Add-DhNetworkMap '$Id': edge #$n Direction must be none, forward or both. Got: $($e['Direction'])" } $style = if ($e.Contains('Style') -and $e['Style']) { ([string]$e['Style']).ToLowerInvariant() } else { 'solid' } if ($style -notin 'solid','dashed') { throw "Add-DhNetworkMap '$Id': edge #$n Style must be solid or dashed. Got: $($e['Style'])" } $lt = (& $str $e 'LinkTableId').Trim(); $lf = (& $str $e 'LinkFilter') if ($lf -and -not $lt) { throw "Add-DhNetworkMap '$Id': edge #$n - LinkFilter requires LinkTableId." } [ordered]@{ From = $from To = $to Label = & $str $e 'Label' Status = & $statusOf $(if ($e.Contains('Status')) { $e['Status'] } else { '' }) "edge #$n" Style = $style Direction = $dir Tooltip = & $str $e 'Tooltip' LinkTableId = $lt LinkFilter = $lf } }) $Report.Blocks.Add([ordered]@{ BlockType = 'networkmap' Id = $Id Title = $Title Nodes = $normNodes Edges = $normEdges Groups = $normGroups Layout = $Layout NodeWidth = $NodeWidth NodeHeight = $NodeHeight Height = $Height Legend = [bool]$Legend.IsPresent NavGroup = $NavGroup NavSubGroup = $NavSubGroup NavPath = $NavPath NavSegments = $navDecl.Path NavSeqNo = $navSeqNo NavScope = $navDecl.Scope NavLabel = $NavLabel NavOrder = $NavOrder GridSpan = $GridSpan Collapsible = $Collapsible DefaultOpen = $DefaultOpen }) Write-Verbose "Add-DhNetworkMap: '$Id' ($($normNodes.Count) node(s), $($normEdges.Count) edge(s), $($normGroups.Count) group(s), layout=$Layout)." } |