Explore WPF Controls with PowerShell

If you’ve ever tried creating a tool or an application with WPF, you know that the built-in controls contain many properties, methods and events, and finding the right one to use can be, well, fun!

As an aid to creating WPF applications, I created this simple tool which explores the various controls and exposes their properties, methods and events. It’s handy as a quick reference, or as a convenient way to get familiar with the various controls.

The tool is a PowerShell script, so simply download the script, right-click and ‘Run with PowerShell’.

More info here: https://smsagent.wordpress.com/tools/wpf-control-explorer/


Create a WPF Application with Navigation using PowerShell

In WPF, there are two types of window which can host your application content.  The most commonly used is simply the window (System.Windows.Window in .Net).  But there is another window type, the navigation window (System.Windows.Navigation.NavigationWindow) which takes a slightly different form and includes some basic navigation controls similar to what you would use in a web browser.


This kind of window can be useful in certain situations such as hosting documentation or help content, or recording input over several pages, as the user can go back and forward between the content pages using the navigation history.

The key to making the navigation window work is to use a page control (System.Windows.Control.Page) to host your content.  Like the window, a page can only contain a single child control, so you need to use a layout control like a grid to contain other window elements, such as textboxes and buttons etc.  You can then simply call the relevant page when you navigate.

Below is a simple example of a navigation window which has two actual pages, and the third “page” is a website.  The window itself and the pages are defined in XAML, stored to a hash table, then called by simply setting the Content property of the navigation window. In the case of the web page, we use the navigation service to load the page.

When the window is opened, the first page is displayed:


Clicking next loads the second page:



Clicking next again loads the web page:


You’ll notice that after you leave the first page the navigation history is enabled and you can go back to the previous pages.

PowerShell Navigation Window

Add-Type -AssemblyName PresentationFramework

# Define Navigation Window in XAML
[XML]$Xaml = @"
<NavigationWindow xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Name = "NavWindow" Width = "600" Height = "400" WindowStartupLocation = "CenterScreen" ResizeMode = "CanMinimize" >

# Define Page 1 text
$Page1_Text1 = @"
Amazon has confirmed its virtual assistant Echo speakers are coming to the UK, Germany and Austria.
Until now, the company sold its voice-controlled devices only in the US.
$Page1_Text2 = @"
The machines can answer questions, control other internet-connected devices, build shopping lists and link in to dozens of third-party services including Spotify, Uber and BBC News.
Experts say they appeal to early adopters' sense of curiosity but tend to be a harder sell to others.

# Define Page 2 text
$Page2_Text1 = @"
The company set up to manufacture Europe's next-generation rocket - the Ariane 6 - says it is open to orders.
Airbus Safran Launchers (ASL) expects to introduce the new vehicle in 2020.
$Page2_Text2 = @"
This week, member states of the European Space Agency gave their final nod to the project following an extensive review process.
The assessment confirmed the design and performance of the proposed rocket, and the development schedule that will bring it into service.

# Define Page 1 in XAML
[XML]$Page1 = @"
<Page      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"     x:Name = "Page1" Title="Amazon Echo"     >
        <TextBlock Padding = "10" TextWrapping = "Wrap" FontFamily = "Segui" FontSize = "18" Height = "Auto" Width = "Auto">
            $Page1_Text1 <LineBreak /><LineBreak />
            $Page1_Text2 <LineBreak />
        <ListBox BorderThickness = "0" Width = "100" HorizontalAlignment = "Left" FontSize = "16">
            <ListBoxItem Content = "That's great!" Foreground = "Green" />
            <ListBoxItem Content = "I hate it!" Foreground = "Red" />
        <DockPanel Margin = "480,280,0,0">
            <Button x:Name = "P1_Next" DockPanel.Dock = "Right" Content = "Next" Height = "30" Width = "50" />

# Define Page 2 in XAML
[XML]$Page2 = @"
<Page      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"     x:Name = "Page2" Title="Ariane 6"     >
        <TextBlock Padding = "10" TextWrapping = "Wrap" FontFamily = "Segui" FontSize = "18" Height = "Auto" Width = "Auto">
            $Page2_Text1 <LineBreak /><LineBreak />
            $Page2_Text2 <LineBreak />
        <ListBox BorderThickness = "0" Width = "100" HorizontalAlignment = "Left" FontSize = "16">
            <ListBoxItem Content = "That's great!" Foreground = "Green" />
            <ListBoxItem Content = "I hate it!" Foreground = "Red" />
        <DockPanel Margin = "480,280,0,0">
            <Button x:Name = "P2_Next" DockPanel.Dock = "Right" Content = "Next" Height = "30" Width = "50" />

# Create hash table, add WPF named elements and objects
$hash = @{}
$Hash.NavWindow = [Windows.Markup.XamlReader]::Load((New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList $xaml))
$Hash.Page1 = [Windows.Markup.XamlReader]::Load((New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList $Page1))
$Hash.Page2 = [Windows.Markup.XamlReader]::Load((New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList $Page2))

$xaml.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object -Process {
    $hash.$($_.Name) = $hash.NavWindow.FindName($_.Name)
$Page1.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object -Process {
    $hash.$($_.Name) = $hash.Page1.FindName($_.Name)
$Page2.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object -Process {
    $hash.$($_.Name) = $hash.Page2.FindName($_.Name)

# Add code to handle the button click events, loading the next page
    $hash.NavWindow.Content = $Hash.Page2
    $Hash.NavWindow.Title = "Clear path to Ariane 6 rocket introduction"

    $Hash.NavWindow.Title = "BBC Website"

# Set the window content to the first page on load
$hash.NavWindow.Content = $Hash.Page1
$Hash.NavWindow.Title = "Amazon's Echo speakers head to UK and Germany"

# Show the NavWindow
$null = $Hash.NavWindow.Dispatcher.InvokeAsync{$Hash.NavWindow.ShowDialog()}.Wait()

Forcing a ConfigMgr Client to Send a New CCMEval Report

In order to maintain a healthy ConfigMgr environment, it is important to know that your clients have successfully run the Configuration Manager Health Evaluation task and reported the results to the Site server.  Sometimes you will find a number of systems that have not reported any health status to the Site server.  In the Devices node of the ConfigMgr Console, you will find “No Results” for the Client Check Result, and the Client Check Detail tab displays nothing, even though the system may be active.



To identify the list of active systems that either have not reported health evaluation results, or have failed the evaluation, I use the following SQL query:

sys.Name0 as 'Computer Name',
sys.User_Name0 as 'User Name',
case when summ.ClientActiveStatus = 0 then 'Inactive'
 when summ.ClientActiveStatus = 1 then 'Active'
 end as 'ClientActiveStatus',
case when summ.IsActiveDDR = 0 then 'Inactive'
 when summ.IsActiveDDR = 1 then 'Active'
 end as 'IsActiveDDR',
case when summ.IsActiveHW = 0 then 'Inactive'
 when summ.IsActiveHW = 1 then 'Active'
 end as 'IsActiveHW',
case when summ.IsActiveSW = 0 then 'Inactive'
 when summ.IsActiveSW = 1 then 'Active'
 end as 'IsActiveSW',
case when summ.ISActivePolicyRequest = 0 then 'Inactive'
 when summ.ISActivePolicyRequest = 1 then 'Active'
 end as 'ISActivePolicyRequest',
case when summ.IsActiveStatusMessages = 0 then 'Inactive'
 when summ.IsActiveStatusMessages = 1 then 'Active'
 end as 'IsActiveStatusMessages',
case when LastHealthEvaluationResult = 1 then 'Not Yet Evaluated'
 when LastHealthEvaluationResult = 2 then 'Not Applicable'
 when LastHealthEvaluationResult = 3 then 'Evaluation Failed'
 when LastHealthEvaluationResult = 4 then 'Evaluated Remediated Failed'
 when LastHealthEvaluationResult = 5 then 'Not Evaluated Dependency Failed'
 when LastHealthEvaluationResult = 6 then 'Evaluated Remediated Succeeded'
 when LastHealthEvaluationResult = 7 then 'Evaluation Succeeded'
 end as 'Last Health Evaluation Result',
case when LastEvaluationHealthy = 1 then 'Pass'
 when LastEvaluationHealthy = 2 then 'Fail'
 when LastEvaluationHealthy = 3 then 'Unknown'
 end as 'Last Evaluation Healthy',
case when summ.ClientRemediationSuccess = 1 then 'Pass'
 when summ.ClientRemediationSuccess = 2 then 'Fail'
 else ''
 end as 'ClientRemediationSuccess',
from v_CH_ClientSummary summ
inner join v_R_System sys on summ.ResourceID = sys.ResourceID
where summ.LastEvaluationHealthy in (2,3)
and summ.ClientActiveStatus = 1
order by summ.LastActiveTime Desc

In most cases where the evaluation status reports “Unknown” by this query, you will find that the client has actually run the health evaluation task, it just hasn’t reported the results to the management point for some reason.  I published a PowerShell script previously that lets you view the current health evaluation status on any remote computer by reading the CCMEvalReport.xml file – you can find the script here.

For these “Unknown” status systems, however, I want to force the client to send a health evaluation report to its management point, so I prepared the following PowerShell script to do that.  It can run either against the local computer, or a remote computer.  Admin rights are required on the target system, and if running against the local computer the script must be run as administrator.

The script simply sets the SendAlways flag for CCMEval reports in the registry to “TRUE”, triggers the CM Health Evaluation task to run, waits for it to finish, then changes the SendAlways flag back to “FALSE”.  When the CCMEval program runs with the SendAlways flag set, it will always send the report to the management point even if the client health status has not changed since the last report.

You can verify that from the CcmEval.log on the client:


Within a few minutes you should find that the status for that system has been updated in the ConfigMgr Console with the health evaluation results.

To run the script against the local machine, run PowerShell as administrator and simply do:


To run against a remote computer:

Send-CCMEvalReport -ComputerName PC001

The script also supports verbose output:

Send-CCMEvalReport -ComputerName PC001 -Verbose


Here’s the full code:


        [String]$ComputerName = $env:COMPUTERNAME

# Code to set 'SendAlways' in registry
$SendAlways = {
    $Path = "HKLM:\Software\Microsoft\CCM\CcmEval"

        $null = New-ItemProperty -Path $Path -Name 'SendAlways' -Value $Value -Force -ErrorAction Stop

# Run against local computer
If ($ComputerName -eq $env:COMPUTERNAME)
    If (!([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator"))
        Write-Warning "This cmdlet must be run as administrator against the local machine!"

    Write-Verbose "Enabling 'SendAlways' in registry"
    $Result = Invoke-Command -ArgumentList "TRUE" -ScriptBlock $SendAlways 

    If (!$Result.Exception)
        Write-Verbose "Triggering CM Health Evaluation task"
        Invoke-Command -ScriptBlock { schtasks /Run /TN "Microsoft\Configuration Manager\Configuration Manager Health Evaluation" /I }

        Write-Verbose "Waiting for ccmeval to finish"
        do {} while (Get-process -Name ccmeval -ErrorAction SilentlyContinue)

        Write-Verbose "Disabling 'SendAlways' in registry"
        Invoke-Command -ArgumentList "FALSE" -ScriptBlock $SendAlways
        Write-Error $($Result.Exception.Message)
# Run against remote computer
    Write-Verbose "Enabling 'SendAlways' in registry"
    $Result = Invoke-Command -ComputerName $ComputerName -ArgumentList "TRUE" -ScriptBlock $SendAlways

    If (!$Result.Exception)
        Write-Verbose "Triggering CM Health Evaluation task"
        Invoke-Command -ComputerName $ComputerName -ScriptBlock { schtasks /Run /TN "Microsoft\Configuration Manager\Configuration Manager Health Evaluation" /I }

        Write-Verbose "Waiting for ccmeval to finish"
        do {} while (Get-process -ComputerName $ComputerName -Name ccmeval -ErrorAction SilentlyContinue)

        Write-Verbose "Disabling 'SendAlways' in registry"
        Invoke-Command -ComputerName $ComputerName -ArgumentList "FALSE" -ScriptBlock $SendAlways
        Write-Error $($Result.Exception.Message)

PowerShell Stopwatch

In PowerShell scripts it is sometimes helpful to use a timer, for example to measure how long a certain task takes.  If you create GUI applications with PowerShell, it can be useful to display a timer during a long-running task.  It’s actually quite simple to do and there are plenty of examples for C# programmers, but not for PowerShell scripters, so I thought I would write this quick post to demonstrate how it can be done.

This example uses a WPF window to display a timer as a stopwatch application, but you can of course re-use the code for your needs.  The System.Diagnostics.Stopwatch class can be used to measure time, and the System.Windows.Forms.Timer class can be used to display the time in a GUI window via the Tick event.



# Load Assemblies
Add-Type -AssemblyName PresentationFramework, System.Windows.Forms

# Define XAML code
[xml]$xaml = @"
        Title="Stopwatch" Height="273.112" Width="525" ResizeMode="CanMinimize">
        <TextBox x:Name="Time" HorizontalContentAlignment="Center" IsReadOnly="True" VerticalContentAlignment="Center" FontSize="80" FontFamily="Segui" BorderThickness="0" HorizontalAlignment="Left" Margin="11,10,0,0" TextWrapping="Wrap" Text="00:00:00" VerticalAlignment="Top" Height="94" Width="496"/>
        <Button x:Name="Start" Content="Start" HorizontalAlignment="Left" FontSize="45" Background="GreenYellow" Margin="11,124,0,0" VerticalAlignment="Top" Width="154" Height="104"/>
        <Button x:Name="Stop" Content="Stop" HorizontalAlignment="Left" FontSize="45" Background="Tomato" Margin="180,124,0,0" VerticalAlignment="Top" Width="154" Height="104"/>
        <Button x:Name="Reset" Content="Reset" HorizontalAlignment="Left" FontSize="45" Background="Aquamarine" Margin="351,124,0,0" VerticalAlignment="Top" Width="154" Height="104"/>


# Load XAML elements into a hash table
$script:hash = [hashtable]::Synchronized(@{})
$hash.Window = [Windows.Markup.XamlReader]::Load((New-Object -TypeName System.Xml.XmlNodeReader -ArgumentList $xaml))
$xaml.SelectNodes("//*[@*[contains(translate(name(.),'n','N'),'Name')]]") | ForEach-Object -Process {
    $hash.$($_.Name) = $hash.Window.FindName($_.Name)

# Create a stopwatch and a timer object
$Hash.Stopwatch = New-Object System.Diagnostics.Stopwatch
$Hash.Timer = New-Object System.Windows.Forms.Timer
    $Hash.Timer.Enabled = $true
    $Hash.Timer.Interval = 55

# Start button event

    $Hash.Timer.Add_Tick({$Hash.Time.Text = "$($Hash.Stopwatch.Elapsed.Minutes.ToString("00")):$($Hash.Stopwatch.Elapsed.Seconds.ToString("00")):$($Hash.Stopwatch.Elapsed.Milliseconds.ToString("000"))"})

# Stop button event
    if (!$Hash.Stopwatch.IsRunning) { return }


# Reset button event

    if ($Hash.Stopwatch.IsRunning) { return }
    $Hash.Time.Text = "00:00:00"


# Display Window
$null = $hash.Window.ShowDialog()

New Free App – ConfigMgr Deployment Reporter

Just released a new free application for ConfigMgr admins – ConfigMgr Deployment Reporter.  I developed this app for use in the organisation I currently work for, and it turned out quite well, so I decided to release a public version to the community!


I developed this app as an alternative (and IMO easier) way to report on ConfigMgr deployments than using the ConfigMgr console. It uses a little different format than the console node allowing you to select which deployment you wish to view data for based on the “feature type” (ie application, package etc) and report on only that deployment.  It also introduces a separation of results between all applicable systems for a deployment, and only those systems which have currently reported status, which allows for a more accurate view of the success of a deployment as it progresses.

The app allows the creation of charts and HTML-format reports to give a nice graphical snapshot of a deployment.

I also added the capability to report per-device for Software Update and Task Sequence deployments.  For Software Updates, this allows you to see which updates from the deployment are applicable to the machine and the status of each update, and for Task Sequences it allows viewing the execution status of each step in the task sequence for the selected device.

As usual, I code purely in PowerShell using WPF for the UI.  This time I added metro styling using the excellent MahApps.Metro project🙂

Download the app from here.

Testing for Local Administrator Privilege with PowerShell

When writing PowerShell scripts it is sometimes necessary to know whether the user account running the script has local administrator privileges.  A piece of code often used for this is:

([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")

However, this code only returns true when run in an elevated context, so it is not really a test of whether the user has local administrator privilege, but whether the code is running in an elevated context.


The reason for this is that since Windows Vista and the introduction of  User Account Control (UAC), an account that is a member of the local administrator group will get a split user access token.  For most tasks, the account will use a filtered access token, which has the same rights as a standard user token.  When a task is performed that requires elevation of privilege, the full access token will be used instead.  Hence we get different results from the above command depending on which access token is being used.

Another way of ensuring that code is run elevated (since PowerShell 4) is to use the requires statement

#requires –runasadministrator

But I want to know if the user is a member of the local administrator group.  One way to do that is simply get the username of the logged-on user from WMI, then use net localgroup:

$LoggedOnUsername = (Get-WmiObject -Class Win32_ComputerSystem -Property Username | Select -ExpandProperty Username).Split('\')[1]
Net localgroup administrators | Select-String $LoggedOnUsername

And here is another method using WMI:

$userToFind = (Get-WmiObject -Class Win32_ComputerSystem -Property Username | Select -ExpandProperty Username).Split('\')[1]
$administratorsAccount = Get-WmiObject Win32_Group -filter "LocalAccount=True AND SID='S-1-5-32-544'"
$administratorQuery = "GroupComponent = `"Win32_Group.Domain='" + $administratorsAccount.Domain + "',NAME='" + $administratorsAccount.Name + "'`""
$user = Get-WmiObject Win32_GroupUser -filter $administratorQuery | select PartComponent |where {$_ -match $userToFind}

The problem with both of these methods is that they wont work with nested groups.  So if a user is a member of a group that is a member of the local administrators group, rather than a direct member, I can’t use these methods.

It can be done, however.

If we go back to our original code we can see that we are creating a Windows Identity object for the current user.  This object has a Claims property, that lists the rights that the user has, mostly in the form of local or domain users or groups.  In the Value property of Claims, we can see the SIDs of those users and groups.  This will include nested groups, because even though the user may not be a direct member of the group, they still have the rights of that group.

Partial listing of SIDs

SIDs are not too friendly by themselves, so lets create a custom object and translate the SIDs to group names:

$Claims = @()
[Security.Principal.WindowsIdentity]::GetCurrent().Claims | foreach {
    $Claim = New-Object psobject
        $SID = New-Object Security.Principal.SecurityIdentifier -ArgumentList $_.Value
        $SID = $SID.Translate([System.Security.Principal.NTAccount])
        Add-Member -InputObject $Claim -MemberType NoteProperty -Name Claim -Value $SID
        Add-Member -InputObject $Claim -MemberType NoteProperty -Name SID -Value $_.Value
        Add-Member -InputObject $Claim -MemberType NoteProperty -Name Type -Value $_.Type.Split('/')[8]
        Add-Member -InputObject $Claim -MemberType NoteProperty -Name Properties -Value $_.Properties.Values
        $Claims += $Claim
    Catch {}
$Claims | sort Claim

User Identity Claims

You’ll notice that the “BUILTIN\Administrators” group is listed in the Claims.  This is the local Administrators group.  Since the SID for this group is the same on all systems, we can search the Claims of the User Identity for this SID, and thereby determine if he or she is a member of the local Administrators group, either directly or indirectly.

We can either use the HasClaim() method:

$LoggedOnUsername = (Get-WmiObject -Class Win32_ComputerSystem -Property Username | Select -ExpandProperty Username).Split('\')[1]
$ID = New-Object Security.Principal.WindowsIdentity -ArgumentList $LoggedOnUsername

This will return True or False based on whether the Identity has the claim of that group.

Or we can also simply do a text search on the Value property:

$LoggedOnUsername = (Get-WmiObject -Class Win32_ComputerSystem -Property Username | Select -ExpandProperty Username).Split('\')[1]
$ID = New-Object Security.Principal.WindowsIdentity -ArgumentList $LoggedOnUsername

To make it even simpler, we can do a one-liner using the GetCurrent() method:


Now I can use this in a script:

if ([Security.Principal.WindowsIdentity]::GetCurrent().Claims.Value.Contains('S-1-5-32-544'))
    "You got the power!"
    "You don't got the power :("

Have I got the power? Oh yeah🙂



Detect an Active VPN Adapter During ConfigMgr Deployments

A common requirement with ConfigMgr deployments is to exclude clients that are connected to the corporate network via a VPN, when the total size of the content files for the deployment are too much to be throwing down a slow network link. There is more than one way to do this, but I have seen that not all are reliable and do not work in every case or for every VPN adapter out there.

For example, using PowerShell, you can run either of the following WMI queries to potentially detect an active VPN adapter (your VPN adapter description may be different):

Using Win32_NetworkAdapter

Get-WmiObject -Query "Select * from Win32_NetworkAdapter where Name like '%VPN%' and NetEnabled='True'"

Using Win32_NetworkAdapterConfiguration

Get-WmiObject -Query "Select * from Win32_NetworkAdapterConfiguration where Description like '%VPN%' and IPEnabled='True'"

Since Windows 8 / Server 2012 you can also use the Get-VPNConnection cmdlet:

(Get-VpnConnection -AllUserConnection).where{$_.Name -like "*VPN*" -and $_.ConnectionStatus -eq "Connected"}

Another method is simply:

ipconfig | Select-String 'PPP adapter'

But my preferred method is to check the IPv4 routing table. This is because VPN connections typically use their own subnet, so when connected they will add entries to the IP routing table for that subnet, and will remove them again when disconnected. If you know the subnets used by your VPN connections, you can query for them in WMI:

Get-WmiObject -Query "Select * from Win32_IP4RouteTable where Name like '10.0.99.%' or Name like '10.15.99.%' 

To use this with Application deployments in ConfigMgr, you can create a Global Condition with a script setting.  This condition could be used either to target or to exclude systems using VPN:


Here is an example script that returns “VPN-Active” or “VPN-InActive” based on whether a VPN subnet is detected:

If (Get-WmiObject -Query "Select * from Win32_IP4RouteTable where Name like '10.0.99.%' or Name like '10.15.99.%'")
    {Write-host "VPN-Active"}
Else {Write-host "VPN-InActive"}

You can then add this as a requirement to an application:


For task sequences, you can use a WMI query condition:

WMI Query

Select * from Win32_IP4RouteTable where Name like '10.0.99.%' or Name like '10.15.99.%'



The only concession is if your VPN subnets ever change, you will need to update them in ConfigMgr.

Finding the ‘LastLogon’ Date from all Domain Controllers with PowerShell

In an Active Directory environment, probably the most reliable way to query the last logon time of a computer is to use the Last-Logon attribute.  The Last-Logon-Timestamp attribute could be used, but this will not likely be up-to-date due to the replication lag.  If you are using PowerShell, the LastLogonDate attribute can also be used, however this is also a replicated attribute which suffers from the same delay and potential inaccuracy.

The Last-Logon attribute is not replicated, however, it is only stored on the DC that the computer authenticated against.  If you have multiple domain controllers, you will get multiple values for this attribute depending on which DC the computer has authenticated with and when.

To find the Last-Logon date from the DC that the computer has most recently authenticated with, you need to query all domain controllers for this attribute, then select the most recent.

Following is a PowerShell script I wrote that will read a list of domain controllers from an Active Directory OU, query each one, then return the most recent Last-Logon value.  It uses parallel processing to return the result more quickly than processing each DC in turn, which is useful in a multi-DC environment.

To use the script, simply pass the computer name and optionally the AD OU containing your domain controllers, to the function.  You can hard-code the ‘DomainControllersOU’ parameter in the script if you prefer, so you don’t need to call it.  You need the Active Directory module installed to use this.


Get-ADLastLogon -ComputerName PC001 -DomainControllersOU "OU=Domain Controllers,DC=contoso,DC=com"



function Get-ADLastLogon {

        [string]$DomainControllersOU = "OU=Domain Controllers,DC=contoso,DC=com",


    # Multithreading function
    function Invoke-InParallel {
            [parameter(Mandatory = $True,ValueFromPipeline=$true,Position = 0)]
            [parameter(Mandatory = $True)]
            $ThrottleLimit = 32,

            # Create runspacepool, add code and parameters and invoke Powershell
                $SessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
                $script:RunspacePool = [runspacefactory]::CreateRunspacePool(1,$ThrottleLimit,$SessionState,$host)

            # Function to start a runspace job
            function Start-RSJob
                    [parameter(Mandatory = $True,Position = 0)]
                if ($RunspacePool.GetAvailableRunspaces() -eq 0)
                        do {}
                        Until ($RunspacePool.GetAvailableRunspaces() -ge 1)

                $PowerShell = [powershell]::Create()
                $PowerShell.runspacepool = $RunspacePool
                foreach ($Argument in $Arguments)
                $job = $PowerShell.BeginInvoke()

                # Add the job and PS instance to the arraylist
                $temp = '' | Select-Object -Property PowerShell, Job
                $temp.PowerShell = $PowerShell
                $temp.Job = $job


        # Start a 'timer'
        $Start = Get-Date

        # Define an arraylist to add the runspaces to
        $script:Runspaces = New-Object -TypeName System.Collections.ArrayList

            # Start an RS job for each computer
            $InputObject | ForEach-Object -Process {
                Start-RSJob -Code $Scriptblock -Arguments $_, $ComputerName

            # Wait for each script to complete
            foreach ($item in $Runspaces)
                until ($item.Job.IsCompleted -eq 'True')

            # Grab the output from each script, and dispose the runspaces
            $return = $Runspaces | ForEach-Object -Process {

            # Stop the 'timer'
            $End = Get-Date
            $TimeTaken = [math]::Round(($End - $Start).TotalSeconds,2)

            # Return the results

    # Get list of domain controllers from OU
    try {
    Import-Module ActiveDirectory | out-null
    $DomainControllers = Get-ADComputer -Filter * -SearchBase $DomainControllersOU -Properties Name -ErrorAction Stop | Select -ExpandProperty Name | Sort
    catch {}

    # Define Code to run in each parallel runspace
    $Code = {
        Import-Module ActiveDirectory | out-null
        $Date = [datetime]::FromFileTime((Get-ADComputer -Identity $ComputerName -Server $DC -Properties LastLogon | select -ExpandProperty LastLogon))
        $Result = '' | Select 'Domain Controller','Last Logon'
        $Result.'Domain Controller' = $DC
        $Result.'Last Logon' = $Date
        Return $Result

    # Run code in parallel
    $Result = Invoke-InParallel -InputObject $DomainControllers -Scriptblock $Code -ComputerName $ComputerName -ThrottleLimit 64

    # Return most recent logon date
    return $Result | sort 'Last Logon' -Descending | select -First 1