Public/Add-DhTreeTable.ps1

function Add-DhTreeTable {
    <#
    .SYNOPSIS
        Add a directory explorer: a tree of OUs, groups, users and computers with a property panel,
        group membership and nesting.

    .DESCRIPTION
        One block with two panes. The left pane is a tree (expand and collapse, find box, type
        filter, status dot, flag chips, a count on containers and groups). The right pane shows the
        selected object in tabs:
          Properties the label/value pairs you supply, plus any extra sections
          Members (groups) who is in the group; click a member to go to it
          Member of which groups the object is directly in
          Nesting (groups) the groups nested below, and for any object every path up to the
                       top-level groups that grant it membership (effective membership). A circular
                       nesting is detected and marked, not followed forever.

        The data is two flat lists, so a collector can emit it without recursion:
          -Rows one entry per object: Id, ParentId (the OU or container it sits in), Type,
                        Label, plus optional Status, Flags, Properties, Detail
          -Memberships one entry per membership: GroupId and MemberId (a member can be a user, a
                        computer or another group)

        It is for browsing, not for thousands of rows: rows are drawn only when their parent is
        open, and the cmdlet warns above -MaxRows and throws above 20000 rows. For a big estate
        use Add-DhTreeFilter (OUs only) plus a table.

        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 explorer.
    .PARAMETER Rows
        Array of hashtables:
          @{ Id='grp-da'; ParentId='ou-groups'; Type='group'; Label='Domain Admins'
             Status='warn'; Flags=@('privileged')
             Properties=[ordered]@{ 'Distinguished name'='CN=Domain Admins,...'; 'Members'='4' }
             Detail=<text or sections> }
        Id, Type and Label are required. ParentId is empty for a root. Type is one of ou, group,
        user, computer, container, other. Status is ok | warn | danger | info | nodata.
    .PARAMETER Memberships
        Array of @{ GroupId; MemberId }. Both must be row Ids. GroupId should be a group (a warning
        names any that is not).
    .PARAMETER MaxRows Warn above this many rows (default 5000); throws above 20000.
    .PARAMETER Height Height of both panes in px (default 520).
    .PARAMETER DefaultOpenDepth
        Levels that start expanded (default 1: roots are open, so their children show).
    .PARAMETER MaxNestingPaths
        Most "path up" lines the Nesting tab lists for one object (default 50).

    .EXAMPLE
        Add-DhTreeTable -Report $r -Id 'dir' -Title 'Directory' -Rows @(
            @{ Id='ou-hq'; ParentId=''; Type='ou'; Label='Headquarters' }
            @{ Id='g-da'; ParentId='ou-hq'; Type='group'; Label='Domain Admins'; Flags=@('privileged') }
            @{ Id='u-rossi';ParentId='ou-hq'; Type='user'; Label='adm.rossi'
               Properties=[ordered]@{ 'Last logon'='2026-10-16'; 'Password age'='41 days' } }
        ) -Memberships @(
            @{ GroupId='g-da'; MemberId='u-rossi' }
        )
    #>

    [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[]] $Rows,
        [AllowEmptyCollection()] [object[]] $Memberships = @(),

        [ValidateRange(1, 100000)]
        [int]      $MaxRows = 5000,
        [ValidateRange(240, 1600)]
        [int]      $Height = 520,
        [ValidateRange(0, 99)]
        [int]      $DefaultOpenDepth = 1,
        [ValidateRange(1, 1000)]
        [int]      $MaxNestingPaths = 50,

        [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-DhTreeTable'
    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-DhTreeTable: A block with Id '$Id' already exists in this report. Use a unique Id."
        }
    }

    if (@($Rows).Count -lt 1) { throw "Add-DhTreeTable '$Id': -Rows must contain at least one row." }
    if (@($Rows).Count -gt 20000) {
        throw "Add-DhTreeTable '$Id': $(@($Rows).Count) rows is over the hard limit of 20000. Send the top levels, or use Add-DhTreeFilter with a table."
    }
    if (@($Rows).Count -gt $MaxRows) {
        Write-Warning "Add-DhTreeTable '$Id': $(@($Rows).Count) rows exceeds -MaxRows ($MaxRows). The data is embedded in the page; consider fewer rows."
    }

    $types = @('ou', 'group', 'user', 'computer', 'container', 'other')
    $allowedStatus = @('ok', 'warn', 'danger', 'info', 'nodata', '')

    $ids = @{}
    $norm = foreach ($r in $Rows) {
        if ($r -isnot [System.Collections.IDictionary]) {
            throw "Add-DhTreeTable '$Id': each row must be a hashtable. Got: $($r.GetType().Name)"
        }
        foreach ($req in 'Id', 'Type', 'Label') {
            if (-not $r.Contains($req) -or [string]::IsNullOrWhiteSpace([string]$r[$req])) {
                throw "Add-DhTreeTable '$Id': each row must have a non-empty '$req' key."
            }
        }
        $rid = [string]$r['Id']
        if ($ids.ContainsKey($rid)) { throw "Add-DhTreeTable '$Id': duplicate row Id '$rid'." }
        $ids[$rid] = $true
        $type = ([string]$r['Type']).Trim().ToLowerInvariant()
        if ($type -notin $types) { throw "Add-DhTreeTable '$Id': row '$rid' Type must be one of $($types -join ', '). Got: $($r['Type'])" }
        $status = if ($r.Contains('Status') -and $r['Status']) { ConvertTo-DhStatusName $r['Status'] } else { '' }
        $status = switch ($status) { 'warning' { 'warn' } 'critical' { 'danger' } 'error' { 'danger' } default { $status } }
        if ($status -notin $allowedStatus) { throw "Add-DhTreeTable '$Id': row '$rid' Status must be ok, warn, danger, info or nodata. Got: $($r['Status'])" }
        $props = [ordered]@{}
        if ($r.Contains('Properties') -and $null -ne $r['Properties']) {
            if ($r['Properties'] -isnot [System.Collections.IDictionary]) { throw "Add-DhTreeTable '$Id': row '$rid' Properties must be a hashtable of label = value." }
            foreach ($k in $r['Properties'].Keys) { $props[[string]$k] = $(if ($null -eq $r['Properties'][$k]) { '' } else { [string]$r['Properties'][$k] }) }
        }
        $flags = @(if ($r.Contains('Flags') -and $null -ne $r['Flags']) { @($r['Flags']) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | ForEach-Object { [string]$_ } })
        [ordered]@{
            Id         = $rid
            ParentId   = if ($r.Contains('ParentId') -and -not [string]::IsNullOrWhiteSpace([string]$r['ParentId'])) { [string]$r['ParentId'] } else { '' }
            Type       = $type
            Label      = [string]$r['Label']
            Status     = $status
            Flags      = $flags
            Properties = $props
            Detail     = if ($r.Contains('Detail')) { ConvertTo-DhDetailSpec -Detail $r['Detail'] -Context "Add-DhTreeTable '$Id' row '$rid'" } else { $null }
        }
    }
    $norm = @($norm)

    # Parents exist; walk up with a hashtable to find cycles
    $byId = @{}
    foreach ($n in $norm) { $byId[$n.Id] = $n }
    foreach ($n in $norm) {
        if ($n.ParentId -and -not $byId.ContainsKey($n.ParentId)) { throw "Add-DhTreeTable '$Id': row '$($n.Id)' references unknown ParentId '$($n.ParentId)'." }
        if ($n.Id -eq $n.ParentId) { throw "Add-DhTreeTable '$Id': row '$($n.Id)' cannot be its own parent." }
    }
    foreach ($n in $norm) {
        $seen = @{ ($n.Id) = $true }; $cur = $n.ParentId; $depth = 0
        while ($cur -and $depth -lt 100000) {
            if ($seen.ContainsKey($cur)) { throw "Add-DhTreeTable '$Id': the ParentId chain loops at row '$cur' (starting from '$($n.Id)'). Containers cannot contain themselves; group nesting belongs in -Memberships." }
            $seen[$cur] = $true; $cur = $byId[$cur].ParentId; $depth++
        }
    }

    # Memberships: both ends must be rows; duplicates dropped; a non-group GroupId is a warning
    $links = [System.Collections.Generic.List[object]]::new()
    $linkSeen = @{}
    $m = 0
    foreach ($ms in @($Memberships)) {
        if ($null -eq $ms) { continue }
        $m++
        if ($ms -isnot [System.Collections.IDictionary] -or -not $ms.Contains('GroupId') -or -not $ms.Contains('MemberId')) {
            throw "Add-DhTreeTable '$Id': membership #$m must be @{ GroupId; MemberId }."
        }
        $g = [string]$ms['GroupId']; $mem = [string]$ms['MemberId']
        if (-not $byId.ContainsKey($g))   { throw "Add-DhTreeTable '$Id': membership #$m GroupId '$g' is not a row Id." }
        if (-not $byId.ContainsKey($mem)) { throw "Add-DhTreeTable '$Id': membership #$m MemberId '$mem' is not a row Id." }
        if ($byId[$g].Type -ne 'group') { Write-Warning "Add-DhTreeTable '$Id': membership #$m GroupId '$g' is a $($byId[$g].Type), not a group." }
        $key = "$g`u{1}$mem"
        if ($linkSeen.ContainsKey($key)) { continue }
        $linkSeen[$key] = $true
        $links.Add([ordered]@{ GroupId = $g; MemberId = $mem })
    }

    $Report.Blocks.Add([ordered]@{
        BlockType        = 'treetable'
        Id               = $Id
        Title            = $Title
        Rows             = $norm
        Memberships      = $links.ToArray()
        Height           = $Height
        DefaultOpenDepth = $DefaultOpenDepth
        MaxNestingPaths  = $MaxNestingPaths
        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-DhTreeTable: '$Id' ($($norm.Count) row(s), $($links.Count) membership(s))."
}