Private/Get-HDTConsoleStepNode.ps1
|
function Get-HDTConsoleStepNode { <# .SYNOPSIS Builds the group and step rows that hang beneath one task sequence. .DESCRIPTION THE TREE USED TO STOP AT THE SEQUENCE, and the detail pane said "Steps: 5". A count is not something an administrator can click. The console is meant to have a "drag-and-drop step tree with a properties pane"; this builds that tree's contents, here rather than in the window, so every row can be asserted without a screen. THE ENGINE ALREADY RESOLVED THE ORDER, AND THIS DOES NOT SECOND-GUESS IT. Import-HDTSequenceDocument returns a FLAT, ordered step list in which every step carries the GroupPath it sits under, plus the groups separately. Walking that list in order and creating each group the first time it is named reproduces the nesting exactly, in the order Invoke-HDTTaskSequence would execute - and it means a console and a deployment can never disagree about what runs when. Re-parsing the YAML here would be a second implementation of the thing the engine is tested to death on. A GROUP IS CREATED WHEN ITS FIRST STEP NAMES IT, which is what makes nested groups work with no recursion and no separate tree walk: a GroupPath of ('Install', 'Drivers') creates 'Install' then 'Drivers' under it, and the next step naming the same path finds both. AN EMPTY GROUP HAS NO FIRST STEP, so the two lists are MERGED rather than one being walked. A group an administrator has named but not yet filled is legal - it is what the New Group button creates, and what emptying a group leaves behind - and it names no step and is named by none, so a tree built from the step list alone drew nothing at all and the button looked like it had done nothing. Each group says how many steps preceded it (AfterStep), so opening every group that is due before each step puts it exactly where the document puts it, including between two steps and after the last one. EVERY ROW IS SCOPED TO ITS OWN SEQUENCE OBJECT. Nothing here is keyed on a sequence ID, deliberately - two shares commonly hold task sequences with the SAME id (both of the lab's shares hold a DEMO-M4), and a lookup by id would hang one share's steps under the other share's sequence while each share still looked correct on its own. A STEP ROW SHOWS WHAT WOULD CHANGE A DECISION: the type, because the name is the administrator's prose and the type is what actually runs; the phase, because running in the wrong one is the commonest reason a step is silently skipped; and continueOnError, because it changes what a red deployment means. The per-type properties follow, since they are the difference between "Apply OS" and "apply THAT image". DEPTH IS COUNTED FROM THE EDITOR'S ROOT, NOT THE BROWSER'S. A top-level group is 0 and a step inside it is 1, because these rows fill the task sequence editor's own tree - the browser stops at the sequence (Deployment Workbench's shape) and never shows a step. .PARAMETER Sequence One task sequence row from Get-HDTConsoleWorkspace, carrying Step and Group. .PARAMETER Header The banner the share's rows carry. .INPUTS None. This command does not accept pipeline input. .OUTPUTS System.Management.Automation.PSCustomObject with two properties: Node every group and step row, in display order TopLevel only the rows hanging off the sequence itself, which is what the caller adds to its Children .EXAMPLE Get-HDTConsoleStepNode -Sequence $sequence -Header $header #> [CmdletBinding()] [OutputType([pscustomobject[]])] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNull()] [object] $Sequence, [Parameter(Mandatory = $true, Position = 1)] [ValidateNotNull()] [object] $Header ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $node = New-Object -TypeName System.Collections.ArrayList # A sequence that would not parse has no steps to show, and its own row # already carries the engine's error. The SHAPE is still the shape - a # caller that has to check for two different return types is a caller that # will one day forget. if ($Sequence.Status -eq 'Error') { return [pscustomobject] @{ Node = [pscustomobject[]] @() TopLevel = [pscustomobject[]] @() } } $document = "(Import-HDTSequenceDocument -Path '{0}' -FileSystem (New-HDTFileSystem))" -f $Sequence.Path # Joined GroupPath -> the group's row, so the second step in a group finds # the row the first one created. $groupNode = @{} # Every row that hangs directly off the sequence, in the order it appeared. $topLevel = New-Object -TypeName System.Collections.ArrayList $step = @($Sequence.Step) $groupList = @($Sequence.Group) # THE ROW FOR ONE GROUP PATH, and for every ancestor of it that does not # exist yet. Both halves of the merge below call it - the group list, which # is the only thing that knows about an empty group, and a step's own # GroupPath, which is the only thing that knows about a group a caller # handed over steps for without handing over the group. Whichever reaches a # path first creates it; the other finds it. # # Its output is discarded at every call site: this function returns one # object, and a stray Add() return value would arrive alongside it. $openGroup = { param([string[]] $Path) $walked = @() foreach ($name in @($Path)) { $ancestor = $walked -join "`u{001F}" $walked = $walked + $name $key = $walked -join "`u{001F}" if ($groupNode.ContainsKey($key)) { continue } $groupIndex = $groupList.Count $matched = @($groupList | Where-Object { (@($_.Path) -join "`u{001F}") -eq $key }) # A GROUP'S EDITABLE NAME IS ITS OWN LEG, NOT ITS PATH. The key # on the line is `group:` and it holds this leg alone, so an # editable box showing 'Install \ Drivers' would write that # whole string as the name the moment anybody touched it. The # path is still worth showing - it is what tells one 'Drivers' # from another - so it gets a read-only row of its own, and only # where there is a path to show. $field = @( New-HDTConsoleField -Label 'Group' -Value $name -Property 'group' ) if ($walked.Count -gt 1) { $field = $field + @( New-HDTConsoleField -Label 'Path' -Value ($walked -join ' \ ') ) } $field = $field + @( New-HDTConsoleField -Label 'Task Sequence' -Value $Sequence.Id ) if (@($matched).Count -gt 0) { $groupIndex = [array]::IndexOf($groupList, $matched[0]) $field = $field + @( New-HDTConsoleField -Label 'Runs in' -Value (Get-HDTConsoleDisplayText -Text $matched[0].RunIn -Fallback 'any phase') New-HDTConsoleField -Label 'Condition' -Value (Get-HDTConsoleDisplayText -Text $matched[0].Condition -Fallback '(none)') ) } $row = New-HDTConsoleNode -Depth ($walked.Count - 1) -Kind 'StepGroup' -Status 'Ok' ` -Text $name -Name $name -Field $field ` -Command ('{0}.Group[{1}]' -f $document, $groupIndex) ` -Header $Header $groupNode[$key] = $row [void] $node.Add($row) if ([string]::IsNullOrEmpty($ancestor)) { [void] $topLevel.Add($row) } else { [void] $groupNode[$ancestor].Children.Add($row) } } } # HOW FAR INTO THE ORDER A GROUP OPENED. A group that does not say - a caller # may build the projection by hand - opens where the walk has reached, which # is where it would have been created from a step's path anyway. $afterStep = { param([object] $Group, [int] $Reached) $property = $Group.PSObject.Properties['AfterStep'] if ($null -eq $property) { return $Reached } return [int] $property.Value } $groupAt = 0 for ($index = 0; $index -lt $step.Count; $index++) { $current = $step[$index] # -- every group that opens before this step, in document order while ($groupAt -lt $groupList.Count -and (& $afterStep $groupList[$groupAt] $index) -le $index) { $null = & $openGroup @($groupList[$groupAt].Path) $groupAt++ } # -- the groups this step sits in, for a projection that listed none $path = @($current.GroupPath) $parent = $null if ($path.Count -gt 0) { $null = & $openGroup $path $parent = $groupNode[($path -join "`u{001F}")] } # -- the step itself # WHICH ROWS MAY BE TYPED INTO IS DECIDED HERE. A row that writes names # the key it writes; a row that does not is a report. 'Type' is # deliberately among the reports - a step's properties belong to its # type, so retyping one leaves keys the new type has never heard of, and # Set-HDTStepProperty refuses it for the same reason. # THE STEP'S OWN SETTINGS, AND NOTHING ELSE. This list used to open with # eight rows that repeat what the window already shows: the name is in # the box above the tabs, the type and the group are the row you clicked # in the tree, and Enabled, Runs in, Condition and Continue on error are # the Options tab. Ten rows of which two were the step's own is why the # tab read as a data dump rather than as a properties page. # # MDT'S Properties TAB IS THE PER-TYPE PAGE - "Command line", "Start in", # "Run as" - and this is the generic version of exactly that. A step type # with a page of its own does not get this tab at all. # # THE FACTS ARE NOT LOST, they are where they were already being read: # the tree, the Options tab, and the name box. $field = @() # WHAT THE ROW REPORTS, which is not what the tab edits. These are read # in the tree - what the step is, which phase it runs in, whether it may # fail - and every one of them is answered somewhere else in the window # too, which is why none of them is a box. $report = @( New-HDTConsoleField -Label 'Type' -Value $current.Type New-HDTConsoleField -Label 'Runs' -Value ('step {0} of {1}' -f $current.Index, $step.Count) New-HDTConsoleField -Label 'Group' -Value (Get-HDTConsoleDisplayText -Text ($path -join ' \ ') -Fallback '(none)') New-HDTConsoleField -Label 'Enabled' -Value (Get-HDTConsoleFlagText -Value (-not $current.Disabled)) New-HDTConsoleField -Label 'Runs in' -Value (Get-HDTConsoleDisplayText -Text $current.RunIn -Fallback 'any phase') New-HDTConsoleField -Label 'Condition' -Value (Get-HDTConsoleDisplayText -Text $current.Condition -Fallback '(none)') New-HDTConsoleField -Label 'Continue on error' -Value (Get-HDTConsoleFlagText -Value $current.ContinueOnError) ) # THE KEYS A DEDICATED PAGE OWNS ARE NOT LISTED HERE. MDT never shows a # setting on two tabs of the same dialog - its Format and Partition Disk # page IS that step's Properties tab - and a disk number in two places # is a disk number that can disagree with itself while both boxes look # authoritative. # # Properties remains for everything else, including the step's name: # most step types have no page of their own, and this is the only tab # they get. $owned = @() if ($current.Type -eq 'DiskPartition') { $owned = @('diskNumber', 'wipe', 'style', 'partition', 'layout') } if ($current.Type -eq 'Validate') { $owned = @(Get-HDTValidateCheckDefinition | ForEach-Object { [string] $_.Key }) } # The per-type properties, each writing the key it is named after - the # difference between "Apply OS" and "apply THAT image", and the reason # the tab exists. foreach ($name in @($current.Property.Keys | Sort-Object)) { $value = $current.Property[$name] # A KEY A DEDICATED PAGE OWNS IS STILL REPORTED, just not offered as # a box here. Dropping it outright took it out of the tree row's # summary as well - so a Validate step stopped saying what it # checks, which is the one thing that row is read for. if ($owned -contains [string] $name) { $report = $report + @(New-HDTConsoleField -Label $name -Value ([string] $value)) continue } # A PROPERTY THAT IS NOT A VALUE GETS NO TEXT BOX. [string] on a # list of ordered dictionaries prints # 'System.Collections.Specialized.OrderedDictionary', which says # nothing about the disk - and the row was EDITABLE, so Apply # properties would have written those words into the document and # replaced a partition table with them. # # IT IS STILL SHOWN. The key is in the file, and a Properties tab # that silently omitted one would be lying about what the step # declares. It says how many entries and stays read-only; the tab # that owns the shape is where it is edited. $isTable = $false if ($value -is [System.Collections.IDictionary]) { $isTable = $true } elseif ($value -isnot [string] -and $value -is [System.Collections.IEnumerable]) { $isTable = $true } if (-not $isTable) { $field = $field + @( New-HDTConsoleField -Label $name -Value ([string] $value) -Property $name ) continue } $entry = @($value).Count if ($value -is [System.Collections.IDictionary]) { $entry = @($value.Keys).Count } $field = $field + @( New-HDTConsoleField -Label $name -Value ('{0} entries - a table, not a value' -f $entry) ) } # TWO FACTS ABOUT A STEP ARE WORTH SEEING WITHOUT OPENING IT, and neither # is what KIND of thing it is - which is all Get-HDTConsoleIcon and # Get-HDTConsoleIconColor answer. So the row overrides both. # # A DISABLED STEP HAS TO LOOK DISABLED AT A GLANCE. The reason to switch # one off is usually to run the sequence again and watch what changes, # and the tree is what an administrator checks before they do. The # engine skips it (Invoke-HDTTaskSequence branch 2a); this is the same # fact, on the screen. Grey, because it is inert rather than # interesting - it is not going to do anything at all. # # A STEP THAT IS ALLOWED TO FAIL CHANGES WHAT A GREEN DEPLOYMENT MEANS. # A sequence carrying continueOnError can finish having done less than # it says, which is a deliberate choice somebody made and is invisible # in a tree that draws every step as the same grey gear. Amber, because # it is a tolerance worth noticing and not a fault - red is for a # document that cannot be read. # # DISABLED WINS WHEN BOTH ARE SET: a step that never runs cannot fail, # so tolerating its failure is not a fact about this deployment. $text = '{0}. {1}' -f $current.Index, $current.Name $icon = '' $iconColor = '' if ([bool] $current.ContinueOnError) { $text = '{0}. {1} (continues on error)' -f $current.Index, $current.Name # U+21B7, NOT U+21AA. The hooked arrow has an EMOJI presentation, # so Windows renders it as a boxed pictograph in its own colours and # ignores the Foreground this row asked for - the mark came out as an # amber-ish tile rather than an amber arrow. A curve arrow has no # emoji form and takes the colour it is given, the way the disabled # step's U+2298 does. $icon = [string] ([char] 0x21B7) # clockwise top semicircle arrow - carries on past a failure $iconColor = '#FFB77400' } if ([bool] $current.Disabled) { $text = '{0}. {1} (disabled)' -f $current.Index, $current.Name $icon = [string] ([char] 0x2298) # circled division slash - switched off $iconColor = '#FF767676' } $row = New-HDTConsoleNode -Depth $path.Count -Kind 'Step' -Status 'Ok' ` -Text $text -Name $current.Name -Field $field -Report $report ` -Command ('{0}.Step[{1}]' -f $document, $index) ` -Header $Header -Icon $icon -IconColor $iconColor [void] $node.Add($row) if ($null -eq $parent) { [void] $topLevel.Add($row) } else { [void] $parent.Children.Add($row) } } # Everything the merge has not reached: the groups after the last step, and # every group in a document that is nothing but empty ones. while ($groupAt -lt $groupList.Count) { $null = & $openGroup @($groupList[$groupAt].Path) $groupAt++ } # TWO READINGS OF ONE BUILD. Node is every row in display order, which is # what the flat list needs; TopLevel is only those hanging off the sequence # itself, which is what the caller adds to its Children - the rest are # already wired into their groups above. return [pscustomobject] @{ Node = [pscustomobject[]] @($node) TopLevel = [pscustomobject[]] @($topLevel) } } |