Wednesday, October 16, 2013

A simple GUI for connecting to your servers in PowerShell

This article was the basis for a talk I gave at the Twin Cities PowerShell User Group.  If you are in the Minneapolis area, join us the second Tuesday of the month.

As people reading this blog, we are by definition command line geeks.  We like to sit at a black or blue console with white text and tease information and action out of our systems.  We sit at a white screen and write clever ad hoc scripts to do half a day's work in minutes.  We write snippets to interact with no one as they perform automated tasks in the middle of the night.

But we are also Microsoft geeks.  We are comfortable with GUI interfaces.  We like mousing around just as much as an arcane command.  We know at least two ways to do everything, one with a keyboard, and one with a mouse.  We know that each method is useful in different circumstances.

And because we are using modern versions of Windows, we have the capability of leveraging GUI interfaces for our scripts.

PowerShell is built on .Net.  .Net is designed for Windows software development.  Almost anything you can do in C#, you can do in PowerShell.  And while it can get quite complicated to do complicated things, it is relatively simple to do simple things, because all of the functionality is built into .Net and Windows.

Six months ago, we were spinning up a new network infrastructure in for a new corporate entity.  We were up to a couple dozen servers in numerous sites on several continents.  We had several engineers in remote locations spinning up new servers every day.  We weren't communicating effectively about our respective tasks, and it was getting hard to keep track of what was what and where it was and how to connect to it.

So I threw together a quick script to grab all of the information about our servers from Active Directory, organize it into a sortable table, and use it to quick launch RDP sessions.

When designing forms, it can be very helpful to use a visual forms editor.  Sapien Technologies, makers of PrimalScript, also make PowerShell Studio.  They have a free version, PrimalFormsCE, which has not quite all, but most of the functionality intact.

But this is a simple form, with only two controls on it, and I could have just borrowed the necessary chunks from other scripts to tweak for this one.

First we load the assemblies we need.  I have a long blog article around here somewhere, about Add-Type versus the alternatives.  But it's mostly just a rant, so I will spare you the details.  This is where we load the definitions of the .Net objects we are going to use.

Add-Type -AssemblyName "System.DirectoryServices"
Add-Type -AssemblyName "System.Drawing"
Add-Type -AssemblyName "System.Windows.Forms"


We define the three objects that will make up the form: the Form itself, a DataGridView to display the server table, and a Label to display "Loading…" while we wait.

We don't need anything else, because this is going to be a very simple form.  The data doesn't change much, so I didn't build a mechanism for automatically or manually reloading the data.  Sorting will be triggered through a click event on the DataGridView, and creating remote sessions will be triggered by a double-click event.

$formRDP = New-Object System.Windows.Forms.Form
$labelLoading = New-Object System.Windows.Forms.Label
$dgvServers = New-Object System.Windows.Forms.DataGridView


As simple as those three statements are, they have actually done most of the heavy lifting for us. We are going to customize those objects a bit, but they are now in place with scores of properties with default settings, with most of their expected behavior built in.  The form, for example, already has 126 properties.  It already has everything to look and act like a Windows form.  It has the right look.  It has a working red X in the corner.  It can be resized, minimized or maximized.  It inherits colors and other settings from the user's Windows settings.  And it sits and watches for the user to interact with the form, through the keyboard or mouse or otherwise, all without any extra code from us.  It's all built in to .Net and Windows.

Next we define our customizations of the components.  We'll add event handlers near the very end, after we define the code that the events will trigger.

Form
In the Windows Forms world, the big square thing on screen, the application, the dialog box, the Window, whatever else you want to call it, is a Form object.  All of the things on the form are called Controls of various types.

We give the form a .Name, the .Text it is going to display in its title bar, and an initial .ClientSize.

# formRDP
$formRDP.Name = "formRDP"
$formRDP.Text = "RDP servers"
$formRDP.ClientSize = '454, 330'


A Form has a .Size and a .ClientSize.  The .Size is the overall size of the form, including the title bar and the borders, both of which will inherit settings from the user's Windows profile. The .ClientSize is the size of the form minus the title bar and borders; it's the playing field, the part we'll be playing on, and the part where we will be putting our controls.  So we'll define that, and let Windows automatically handle the stuff around it.

The .ClientSize property actually takes a [system.windows.forms.size] object as input, not a string.  Other sources will tell you to create a new [system.windows.forms.size] object, set its properties as desired, and then feed that into the .ClientSize property.  What a waste of typing.

We are scripters, not software developers.  We write scripts to make lives easier, mostly our own.   Our priority when making stylistic scripting choices is for it to be easy to write, easy to read, easy to understand and easy to modify.  (Without sacrificing quality and functionality, of course.)

One of the best things they put in PowerShell is its facility at dynamic type conversion.  If you give it a string variable where it needs an integer, for example, it will try no less than 10 different ways, if needed, to convert that variable into what it needs it to be.

And in this case, we can tell it the number of pixels we want in our playing field in an easily readable string, without having to invest any effort in knowing or remembering what object type it's supposed to be.  PowerShell does all the work for us.

Then we add the two controls to the form.  The order we add them is important in this case, because it determines which one will cover the other one when both are "visible".

$formRDP.Controls.Add($dgvServers)
$formRDP.Controls.Add($labelLoading)


Label

The label will be visible when the form first loads because we will make the DataGridView not visible to start with.  That way we have a "Loading…" notice instead of just a long pause before the form appears while it is loading and querying Active Directory.  We give it a .Name, the .Text it is going to display, the .Font to display it in, a .Size, and a relative .Location on the form.  Several of these propertires are of weird object types, but again, PowerShell will do the conversion for us.

# labelLoading
$labelLoading.Name = "labelLoading"
$labelLoading.Text = "Loading..."
$labelLoading.Location = '26, 26'
$labelLoading.Size = '100, 23'
$labelLoading.Font = "Microsoft Sans Serif, 12pt, style=Bold"


DataGridView

What's the difference between a DataGrid control and a DataGridView control?  The DataGridView is just a more advanced version of the DataGrid control.  A few versions of .Net ago, they wanted to make some changes in what a DataGrid did, not just enhancements, which meant they would break old scripts and applications.  So to couldn't keep it backwards compatible with old code unless they used a new name for the version with the new functionality.

The DataGridView is going to give us a pretty table to display our data.  We give it a .Name, a .Size, and a relative .Location on the form.

# dgvServers
$dgvServers.Name = "dgvServers"
$dgvServers.Location = '13, 13'
$dgvServers.Size = '429, 305'


Then we define its .Anchor points.   These will determine how it behaves when an end user changes the size of the window.  We wanted our Label, above, to stay put relative to the top left corner of the form, so we did not define a custom .Anchor, leaving it as the default 'Top, Left'.  We want the DataGridView to grow when the form grows, so we will anchor all four of its sides to the sides of the form.  When the window changes sizes, the sides of the DataGridView will maintain their distance from the sides of the Form.

The .Anchor actually just takes an integer from 0 to 15, but if we used that, you would not be able to tell by looking at the script what the number meant, or how to change it when needed.  You could use AnchorStyle enumerations and use bit-wise-or comparisons to join them together, but that would look like this:

$dgvServers.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Bottom -bor [System.Windows.Forms.AnchorStyles]::Left -bor [System.Windows.Forms.AnchorStyles]::Right

So instead, we are going to again take advantage of PowerShell's powerful dynamic type conversion.  We just put in a human readable string, and let PowerShell do the rest.

$dgvServers.Anchor = 'Top, Bottom, Left, Right'

Turn off end-user editing of the data, because we're just displaying it, not monkeying with it.

$dgvServers.AllowUserToAddRows = $False
$dgvServers.AllowUserToDeleteRows = $False
$dgvServers.ReadOnly = $True


We set the column header height to auto.

$dgvServers.ColumnHeadersHeightSizeMode = 'AutoSize'

And we make it invisible, so the Loading... will show until the DataGridView is ready.

$dgvServers.Visible = $False

Event handlers

"Events" are specific things that can happen to objects.  Windows watches for events to occur, and then runs an appropriate chunk of code.  Many of these events and code chunks are built into Windows, and we are leveraging those here.  We don't have to script out what will happen if the end-user clicks the red X in the corner of the form.  (We could override the default event handler if we wanted to, but we are content with the default action of closing the form and ending the script.)

We do need to create three chunks of custom code for handling three events, the initial loading of the form (and the data on it), clicking on a column header (to sort the data), and double click on the data (to launch a remote session to the server).

OnFormShown

You can search MSDN for details on any native .Net object, including a list of the events that Windows will be watching for.  You would think we would want to use the Load event to do stuff related to loading the form, but everything that happens during Load happens before the form is visible, which means there would be an unacceptable delay while our code ran and retrieved data from AD and made it pretty.  So we are going to use the Shown event of the form, which occurs when the form is fully loaded, and first made visible.

# RDP form OnShown event handler
$formRDP_Shown =
{


The first thing we are going to do is change the size, because I realized during testing that the original size we used above isn't big enough.  We could just change the size in the code above where we defined the Form, but if we did that, we would also have to do the math and manually modify the size of the DataGridView to match.  Simple enough in this case, but with a more complex form, with lots of objects and lots of different anchor types, manually changing sizes and locations accurately would become onerous.
But, if we leave all of the original configurations as is, with the objects configured properly relative to each other, and the wait until the form is loaded and running, any time we make a change to the size of the form, Windows will automatically move around and/or resize the controls anchored to it, as appropriate for the configured anchor types.

$formRDP.ClientSize = '900, 700'

Then we .Refresh() the Form to show the changes while we continue with loading the data.  Sometimes Windows waits patiently for your script to finish cranking before it refreshes the screen, and we want to be sure our "Loading…" .Label is displayed as early as possible.

$formRDP.Refresh()

We get the name of a local domain controller.

$DC = ( Get-ADDomainController -Discover -DomainName Contoso.local ).HostName[0]

We query the domain controller for all of the servers in the domain, getting the default properties, plus a few more with information we want to display.

$Servers = Get-ADComputer `
    -Server $DC `
    -Filter 'OperatingSystem -like "*Server*"' `
    -Properties Created, Name, DNSHostName, IPv4Address, OperatingSystem, Description, CanonicalName


Then we extract the exact information we want to display, give it property names that will become the column headers we want, give it an initial sort order, and stick it in an array.  This array variable is scoped at the script level so that it can be referred to later within a function.

In the organization this was originally written for, the first two letters of a server name are the site code, and the next two letters indicate the server role, so I wanted each of those in its own sortable column.

We want to know what OS they are running, but we don't need to have the words "Windows", "Server", and "Datacenter" redundantly showing up repeatedly, making it more difficult to easily see what is what, so we'll strip those out.  In your version of the script, you may strip out additional words, or you may prefer to put some back in.

Lastly we want to know what OU structure they are in, but we don't need to know the domain, so we'll strip that out.  You'll need to modify your version of the script for your domain.

Then sort everything by name.

$Script:ServerData = $Servers `
  | Select -Property `
    Name, `
    @{ Name = "Loc" ; Expression = { $_.Name.Substring(0,2) } }, `
    @{ Name = "Type" ; Expression = { $_.Name.Substring(2,2) } }, `
    Description, `
    IPv4Address, `
    @{ Name = "OS" ; Expression = { $_.OperatingSystem.Replace("Windows Server","").Replace("Datacenter","").Trim(" ") } }, `
    @{ Name = "OU" ; Expression = { $_.CanonicalName.Replace("Constoso.local/","").Replace($_.Name,"").Trim("/") } }, `
    DNSHostName `
   | Sort Name


The data in a DataGridView needs to be in an array list rather than an array, so we create an array list object, add the data to it, then assign it as the data source for the DataGridView.

$Script:ServerGridData = New-Object System.Collections.ArrayList
$Script:ServerGridData.AddRange( $Script:ServerData )
$dgvServers.DataSource = $Script:ServerGridData


Make the DataGridView visible (covering the "Loading…" label), and tell Windows to resize the column widths based on the actual data.

$dgvServers.Visible = $True
$dgvServers.AutoResizeColumns( "AllCells" )
}


OnColumnHeaderMouseClick

This is the function that will run when the end user clicks on a column header, to sort the data alphabetically by that column.  We are using a function instead of a code block so that the event can pass us variables that will include information on which column was clicked.

When we add the event handler--near the end of the script--we will use the built-in variables $This and $_ as parameters for the function.  We'll pick those up within the function as $Sender and $EventArgs.  $Sender won't be used in this particular script, but this is one of those things that I keep standard.

# Servers datagridview column header click handler
function dgvServers_OnColumnHeaderMouseClick ( $Sender, $EventArgs )
{


$EventArgs.ColumnIndex gives us the column number that was clicked on.
$dgvServers.Columns gives us an array with all of the column objects.
$dgvServers.Columns[$EventArgs.ColumnIndex] gives us the column object for the column that was clicked.
$dgvServers.Columns[$EventArgs.ColumnIndex].HeaderText gives us the column header.

Since the column header was automatically created based on the properties in the array, we can reverse the process and use the column header to reference the correct property in the array objects.

$SortProperty = $dgvServers.Columns[$EventArgs.ColumnIndex].HeaderText

So we take our original array and sort it by the desired column, with a secondary sort on the server name.

$SortedServerGridData = $Script:ServerData `
  | Sort-Object -Property $SortProperty, Name


We then recreate the ArrayList as a new, empty object, add the sorted data to it, and make it the new data source for the DataGridView.

$Script:ServerGridData = New-Object System.Collections.ArrayList
$Script:ServerGridData.AddRange( $SortedServerGridData )
$dgvServers.DataSource = $Script:ServerGridData
}


OnDoubleClick

When the end user double clicks on any entry, we want to launch an RDP connection to the server that was double clicked.  The first click of the double click will select the cell.  So we can just reference the SelectedCell to know what was clicked on.

The fully qualified domain name of the server is in the eighth column--column 7 when you start counting at zero.

$dgvServers.SelectedCells gives us an array of selected cell(s).
$dgvServers.SelectedCells[0] gives us the first selected cell (the only cell that can be in the array, because selecting multiple cells is disabled by default).
$dgvServers.SelectedCells[0].RowIndex gives us the row number.
$dgvServers.Rows gives us an array with all of the row objects.
$dgvServers.Rows[$dgvServers.SelectedCells[0].RowIndex] gives us the row object for the row number of the cell that was clicked on.
$dgvServers.Rows[$dgvServers.SelectedCells[0].RowIndex].Cells gives us the cells in that row.
$dgvServers.Rows[$dgvServers.SelectedCells[0].RowIndex].Cells[7] gives us the eighth cell in that row.
$dgvServers.Rows[$dgvServers.SelectedCells[0].RowIndex].Cells[7].Value, at last, gives us the contents of the cell in the eighth column of the row that was double clicked.

# Hosts datagridview double click handler
# Connect to host
$dgvServers_OnDoubleClick =
{
$RemoteServerName = $dgvServers.Rows[$dgvServers.SelectedCells[0].RowIndex].Cells[7].Value


Then all we have to do it tell Windows to launch mstsc and point it to the desired server.  (Mstsc.exe is short for Microsoft Terminal Services Client, which was the old name for the RDP client.)

& mstsc /v:$RemoteServerName
}


Almost done

Lastly we tell Windows to add event handlers for the three events, and tell it what to do if and when any of these three things happen.  For two of them, we give it the name of the variable with the predefined code block.  For one, we put in a code block that calls the defined function and passes it the variables with the information it needs.

# Add event handlers to form
$formRDP.add_Shown( $formRDP_Shown )
$dgvServers.add_DoubleClick( $dgvServers_OnDoubleClick )
$dgvServers.add_ColumnHeaderMouseClick( { dgvServers_OnColumnHeaderMouseClick $this $_ } )


Then we launch the form we just defined.  We pipe it to Out-Null to suppress the "result" of the form after we close it, which would otherwise just be the confusing appearance of the word "Cancel".

# Show the Form
$formRDP.ShowDialog() | Out-Null


The result

When run, the result looks like this.


2 comments:

  1. Hey Tim-

    This is pretty awesome! I currently just use RDC Man (http://www.microsoft.com/en-us/download/details.aspx?id=21101) and set a name and description via PowerShell by modifying the XML config that can be imported.

    Thanks for presenting last night!

    Best Regards,
    -Marcus Felling

    ReplyDelete
  2. This is amazing! I love the power this give us to sort by all the various fields. We have over 100 servers in our enviornment and this will really come in handy. And you did a great job explaning how each part ot the script works. I am hoping to build a GUI for a PowerShell that our Desktop Support team can use to automate user provisioning.
    Your step-by-step in the GUI building you have here is Outstanding.
    Thanks again.

    ReplyDelete