Quantcast
Channel: Hey, Scripting Guy! Blog
Viewing all articles
Browse latest Browse all 3333

Lessons Learned While Writing the PowerShell PoshPAIG Module

$
0
0

Summary: Guest Blogger Boe Prox shares lessons learned while writing the Windows PowerShell PoshPAIG module.

 

Microsoft Scripting Guy Ed Wilson here. Today, we have part two of Boe Prox’s article about his module to audit and update Windows systems. See yesterday's blog post for information about Boe as well as for a detailed article about features and use of the module. Here’s Boe.

 

In the previous post, I introduced several new features to my latest project, PoshPAIG, that greatly improved upon the previous release and allows anyone to patch systems remotely via a graphical user interface (GUI).

As with any script you write, there most likely will be some sort of issue you run into where it doesn’t run properly the first time, bugs are discovered while others are running it, or you think of a better way to make the code run. Creating my utility is by no means any different.

 

Use of XAML (Extensible Application Markup Language)

While I was about halfway into the build of the initial version of PoshPAIG, I switched my code from creating the UI by creating all of the objects for the controls in Windows PowerShell to writing the XAML code using a Here-String and then importing the code using Windows PowerShell. In the example below you will see both styles that produce the exact same output.

Non-XAML

#Load Required Assemblies

Add-Type –assemblyName PresentationFramework

Add-Type –assemblyName PresentationCore

Add-Type –assemblyName WindowsBase

[array]$colors = "red","green","black","gray","blue","yellow","purple"

##Build the GUI

$window = New-Object System.Windows.Window

$Window.Title = "Colors"

$Window.ResizeMode = "NoResize"

$Window.WindowStartupLocation = "CenterScreen"

$Window.SizeToContent = "WidthAndHeight"

$Window.ShowInTaskbar = "True"

$textblock = New-Object Windows.Controls.TextBlock

$textblock.Text = "Click the button to change the text below"

$textblock.Width = '150'

$textblock.Foreground = "Black"

$textblock.TextWrapping = 'Wrap'

$stackpanel = New-Object Windows.Controls.StackPanel

$Button = New-Object Windows.Controls.Button

$Button.Content = "Push Me"

$Button.Width = '60'

$label = New-Object Windows.Controls.Label

$label.Content = "Black"

$label.Foreground = "Black"

[array]$controls = $textblock,$button,$label

ForEach ($control in $controls) {

    $stackpanel.Children.Add($control) | Out-Null

    }

$window.Content = $stackpanel

##Events

#Button Click

$Button.Add_Click({

    $color = Get-Random $colors

    $label.Content = $color

    $label.Foreground = $color

    })

$Window.showdialog()

 

XAML Version

#Load Required Assemblies

Add-Type –assemblyName PresentationFramework

Add-Type –assemblyName PresentationCore

Add-Type –assemblyName WindowsBase

[array]$colors = "red","green","black","gray","blue","yellow","purple"

#Build the GUI

[xml]$xaml = @"

<Window

    xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'

    xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'

    x:Name='Window' Title='Colors' ResizeMode = 'NoResize' WindowStartupLocation = 'CenterScreen'

    SizeToContent = 'WidthAndHeight' ShowInTaskbar = 'True'>

    <StackPanel>

        <TextBlock  Text = 'Click the button to change the text below' Foreground = 'Black' TextWrapping = 'Wrap' Width = '150'/>

        <Button x:Name = 'Button' Content = 'Push Me' Width = '60'/>

        <Label x:Name = 'Label' Content = 'Black' Foreground = 'Black' />

    </StackPanel>

</Window>

"@

$reader=(New-Object System.Xml.XmlNodeReader $xaml)

$Global:Window=[Windows.Markup.XamlReader]::Load( $reader )

##Connect to Controls

$label = $Window.FindName('Label')

$Button = $Window.FindName('Button')

##Events

#Button Event

$Button.Add_Click({

    $color = Get-Random $colors

    $label.Content = $color

    $label.Foreground = $color

    })

$Window.showdialog()

 

The following figures show what you get from both of these. Nothing spectacular, but you get the idea.

Image of results of non-XAML code

 Image of results of XAML code

You might be thinking, “Big deal, Boe. The Non-XAML version had 37 lines of code but the XAML version had 32 lines.” And you would be right. Even though the difference for this small UI is only a few lines of code, imagine the increase of code on a more complex UI. Before I made the switch to XAML, I found that the difference was at least 80 lines of code. I would imagine that with all of my latest additions, it would be more than 100 lines of code. Of course, using ShowUI would reduce the amount of code even more so, but I chose to leave that out as my utility does not currently utilize it yet. Another thing I like about using XAML is how I can completely set up my UI, and then focus on connecting the controls needed and configuring the associated events.

 

UI freezing when performing an operation/Operation performed on one system at a time

Something that was becoming a pain when running this utility initially was that whenever a user would run an operation, the GUI would lock up and act as though it had stopped responding. Even though stuff was still happening (I could tell because I used a lot of Write-Verbose in my code to track what was happening), having the UI freeze would cause the user to have the false sense of a failure occurring. While not an issue per se, the idea of the operation performing against one system at a time could be seen as a major waste of time and would completely deter most folks from wanting to use it as an alternative to other means. This probably took the most of my time to figure out a usable solution that would solve both of these issues. Fortunately, the Windows PowerShell team came out with an excellent blog posting that just happened to fit exactly what I was looking for. Below is a snippet of the code I am using to perform the remote patch installations:

The first piece checks to see if there are systems available on which to install patches and if psexec.exe exists. From there I gather all of the servers in the server list and add them to a queue to be passed into the InstallJobFromQueue function, but create only as many jobs as I specified. In this case, I have a limit of 20 jobs at a time.

    If ($Global:Listview.ItemsSource.Count -gt 0) {

        $Global:ProgressBar.Maximum = $Global:Listview.ItemsSource.count

        $Listview.ItemsSource | ForEach {$_.Notes = $Null}

        #Install Patches

        If ($InstallRadio.isChecked) {

            If (Test-Path psexec.exe) {  

                $runbutton.IsEnabled = $False    

                $Global:StatusTextBox.Foreground = "Black"

                $Global:StatusTextBox.Text = "Installing Patches for all servers...Please Wait"  

                $Global:updatelayout = [Windows.Input.InputEventHandler]{ $Global:ProgressBar.UpdateLayout() }

                $Global:Start = Get-Date

 

                [Float]$Global:ProgressBar.Value = 0

 

                # Read the input and queue it up

                $queue = [System.Collections.Queue]::Synchronized( (New-Object System.Collections.Queue) )

 

                foreach($item in $Global:Listview.ItemsSource | Select -Expand Computer)

                {

                    $queue.Enqueue($item)

                }

 

                # Start up to the max number of concurrent jobs

                # Each job will take care of running the rest

                for( $i = 0; $i -lt $maxConcurrentJobs; $i++ )

                {

                    InstallJobFromQueue

                }

                }

            Else {

                Write-Verbose "PSExec not in same directory as script!"

                $Global:StatusTextBox.Foreground = "Red"

                $Global:StatusTextBox.Text = "PSExec.exe missing from $($pwd)!"        

                }           

            $server = $Null

            }

 

The InstallJobFromQueue function takes the items in the queue and passes them into this function, which will continue to update itself until there are no more items in the queue.

#Function to install patches in background

Function Global:InstallJobFromQueue

{

    if( $queue.Count -gt 0)

    {

        $server = $queue.Dequeue()

        $Global:Listview.DataContext.Rows.Find($server).Notes = "Installing Patches"

        $j = Start-Job -Name $server -ScriptBlock {

                param($server,$location)

                Set-Location $location

                . .\Install-Patches.ps1

                Install-Patches -Computername $server

            } -ArgumentList $server,$Global:path

        Register-ObjectEvent -InputObject $j -EventName StateChanged -Action {

            #Declare value for server to be updated in grid

            $serverupdate = $eventsubscriber.sourceobject.name

            $Global:ProgressBar.Value++

            $Global:Window.Dispatcher.Invoke( "Render", $Global:updatelayout, $null, $null)       

 

            [Array]$Global:Data = Receive-Job -Job $eventsubscriber.sourceobject |

                Select Computer,Title,KB,IsDownloaded,Notes

            Write-Verbose "Updating: $($eventsubscriber.sourceobject.name)"

            If ($Data[0].Title -eq "NA") {

                Write-Verbose "No updates to install"

                $Global:Listview.DataContext.Rows.Find($serverupdate).Installed = 0

                $Global:Listview.DataContext.Rows.Find($serverupdate).Notes = "Completed"

                }

            Else {

                [array]$good = $Data | Where {$_.Notes -ne "Failed to Install Patch"}

                If ($good.count -lt 1) {

                    $Global:Listview.DataContext.Rows.Find($serverupdate).Installed = 0

                    }

                Else {

                    $Global:Listview.DataContext.Rows.Find($serverupdate).Installed = $good.Count

                    }

                $Global:Listview.DataContext.Rows.Find($serverupdate).InstallErrors = ($Data.Count - $good.Count)

                $Global:Listview.DataContext.Rows.Find($serverupdate).Notes = "Completed"

                }

            Write-Verbose "Updating Patch Report"

            [array]$Global:patchreport += $Global:Data

            Write-Verbose "Removing: $($eventsubscriber.sourceobject)"           

            Remove-Job -Job $eventsubscriber.sourceobject

            Write-Verbose "Unregistering: $($eventsubscriber.SourceIdentifier)"

            Unregister-Event $eventsubscriber.SourceIdentifier

            Write-Verbose "Removing: $($eventsubscriber.SourceIdentifier)"

            Remove-Job -Name $eventsubscriber.SourceIdentifier

            If ($queue.count -gt 0 -OR (Get-Job)) {

                Write-Verbose "Running InstallJobFromQueue"

                InstallJobFromQueue

                }

            ElseIf (-NOT (Get-Job))

            {

                [Float]$Global:ProgressBar.Value = $Global:ProgressBar.Maximum

                $End = New-Timespan $Start (Get-Date)                    

                $Global:StatusTextBox.Text = "Completed in: {0}" -f $end

                $Global:Runbutton.IsEnabled = $True

            }           

        } | Out-Null

        Write-Verbose "Created Event for $($J.Name)"

        }

}

 

Because I didn’t want to kick off the last jobs in the queue and move immediately into a “Completed” status, I made sure that each time a job completed, it would clean itself up by removing the job and its related event monitoring jobs. Doing it this away allowed me to use an If/Else statement to monitor the job queue with Get-Job. When the job queue has reached 0, it knows that everything is done and the utility can show a “Completed” status. All during this time, it will continuously update each server in the server list with the number of patches installed and any number of patches that had errors during the installation. Something I didn’t count on and am going to use this to segue into is…
 

Server list data not updating when jobs are completed

As it turns out, the issue with what I was able to achieve by configuring some multithreading using multiple background jobs is that I noticed that none of the data was updating on the server list. Again, using Write-Verbose heavily in my code allowed me to see that stuff was indeed happening. When I stopped the utility, I checked the variable holding the data table in the server list and saw that the data was there, but was not displaying like it should. My solution was found after a little trial and error: set up a timer on the window to automatically refresh the window every five seconds. By doing this, I have allowed the UI to allow the background jobs to update the data table on the server list. The code I used to make this happen is shown here:

#Timer Event

$Window.Add_SourceInitialized({

    #Create Timer object

    Write-Verbose "Creating timer object"

    $Global:timer = new-object System.Windows.Threading.DispatcherTimer

    #Fire off every 5 seconds

    Write-Verbose "Adding 5 second interval to timer object"

    $timer.Interval = [TimeSpan]"0:0:5.00"

    #Add event per tick

    Write-Verbose "Adding Tick Event to timer object"

    $timer.Add_Tick({

        [Windows.Input.InputEventHandler]{ $Global:Window.UpdateLayout() }

        Write-Verbose "Updating Window"

        })

    #Start timer

    Write-Verbose "Starting Timer"

    $timer.Start()

    If (-NOT $timer.IsEnabled) {

        $Window.Close()

        }

    })

 

 Sorting columns

Something that I take for granted in a UI but quickly realized was not an easy thing to implement is the ability to sort a column just by clicking the column header. By not easy to implement, I mean that as a nondeveloper it was not as obvious to me what the solution was until I had done enough research and some trial and error to really get it figured out. The trick was using the SortDescription property for a ListView control. The first thing I had to do was to define all of my column headers in my server list using the following XAML code using GridViewColumnHeader. The following code is a snippet for the ComputerColumnHeader showing the XAML and the subsequent Windows PowerShell code to allow sorting:

                    <GridViewColumn x:Name = 'ComputerColumn' Width = '100' DisplayMemberBinding = '{Binding Path = Computer}'>

                        <GridViewColumnHeader x:Name = 'ComputerColumnHeader'>

                        Computer

                        </GridViewColumnHeader>

                    </GridViewColumn>

 

#Connect to ComputerColumnHeader Control Event

$ComputerColumnHeader = $Global:Window.FindName("ComputerColumnHeader")

 

#Computer columns sort

$ComputerColumnHeader.Add_Click({

    $Listview.Items.SortDescriptions.Clear()

    If ($compsort -eq "descending")  {

        Write-Verbose "Sorting Ascending via Computer Column"

        $comp_ascend = New-Object System.ComponentModel.SortDescription("Computer","Ascending")

        $Listview.Items.SortDescriptions.Add($comp_ascend)

        $Listview.Items.Refresh()

        $compsort = "ascending"

        }

    ElseIf ($compsort -eq "ascending")  {

        Write-Verbose "Sorting Descending via Computer Column"

        $comp_descend = New-Object System.ComponentModel.SortDescription("Computer","Descending")

        $Listview.Items.SortDescriptions.Add($comp_descend)

        $Listview.Items.Refresh()

        $compsort = "descending"

        }

    Else {

        Write-Verbose "Sorting Ascending via Computer Column"

        $comp_ascend = New-Object System.ComponentModel.SortDescription("Computer","Ascending")

        $Listview.Items.SortDescriptions.Add($comp_ascend)

        $Listview.Items.Refresh()

        $compsort = "ascending"  

        }  

    })

 

By connecting to the necessary controls, I am able to add an event that, when the user clicks the column header under the Computer column, will sort the columns by either ascending or descending order based on its previous state. The Refresh() method ensures that the sorted data is updated in the Listview so that the user can see the sorted data.

 

Conclusion

As you can see, I did run into some issues that could be considered show stoppers, but after some research and trial and error, I was able to come up with workable solutions to each issue. There were some other items that were stumping me, but these were my top issues that I felt others may be able to use in their endeavors.

Speaking of anyone working on a UI in Windows PowerShell, if you are on the fence about possibly writing some sort of UI in Windows PowerShell, I say go for it! Whether it is Windows Forms, WPF with or without XAML, or ShowUI, you should give it a shot and see what you can create. It doesn’t matter if it is a small item that is just looking at the processes of a remote system or something much more complex. It is a great opportunity to expand on your knowledge and build something cool in the process.

I hope everyone enjoys my latest project. Feel free to leave feedback on any bugs or features that you might think would be useful for this utility.

 

Boe, thank you for writing an awesome module and for taking the time to share your experiences in developing the project with us.

I invite you to follow me on Twitter and Facebook. If you have any questions, send email to me at scripter@microsoft.com, or post your questions on the Official Scripting Guys Forum. See you tomorrow. Until then, peace.

Ed Wilson, Microsoft Scripting Guy

 

 


Viewing all articles
Browse latest Browse all 3333

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>