Welcome to our new PowerShell Series that deals with PowerShell tricks and how to incorporate them into PowerShell Universal. Here is how this series works:
- The first part of each article deals with plain PowerShell techniques and presents you with surprising tricks and code examples that level up your PowerShell knowledge.
- The second part uses the free tier of PowerShell Universal to build your personal PowerShell dashboard so you can easily utilize your PowerShell code without having to find scripts and run consoles.
PowerShell Universal (PSU)
If you are new to PowerShell Universal, this is the perfect place to start. PowerShell Universal embeds PowerShell code as a web server, and with just a few clicks, you can start building your own GUI dashboards. It perfectly complements your existing PowerShell knowledge and works cross-platform, both with Windows PowerShell and PowerShell 7. With it, you build dashboards and tools without complex WPF, WinForms or other GUI techniques.
Scaling PowerShell functions
Let’s look at some PowerShell code first and apply some tricks and techniques to improve it.
Have you ever wanted to identify which process hosts a specific system service, only to realize that they all show up as “svchost.exe” in Process Monitor or Process Explorer?
The solution involves two PowerShell functions that retrieve service process IDs and service group names, among many other things. They work beautifully; however, they are slow at scale. Have a look:
function Get-ServiceProcessId
{
[CmdletBinding()]
param
(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[string] $Name
)
process
{
$svc = Get-CimInstance Win32_Service -Filter "Name='$Name'"
if ($svc -and ($svc.ProcessId -ne 0))
{
$svc.ProcessId
}
}
}
This awesome function takes a process name and returns its process ID (if available):
PS C:\> Get-ServiceProcessId -Name spooler
4784
Since this function is pipeline-aware, you can scale this to many processes:
PS C:\> Get-Service | Get-ServiceProcessId
5524
1960
(...)
In this call, Get-Service dumps all services, and Get-ServiceProcessId automatically binds the property “Name” to its parameter “Name” (ValueFromPipelineByPropertyName). Since the function code is embedded in a process {} block, this code is then repeated for each pipeline element. Writing such pipeline-aware functions really is no rocket science: designate a parameter with ValueFromPipeline or ValueFromPipelineByPropertyName, and embed your code in process {} – done. Your function now automatically scales.
However, the call is painstakingly slow: the process IDs pour in one by one with seconds of delay. That’s because the function code queries WMI for each and every service, and this WMI query takes time.
Optimizing performance
The performance issue you have just seen is common in many scripts. Whenever you query information, whether it is WMI, Active Directory, data bases or anything else, code doesn’t scale well.
An easy improvement for these scenarios is to create lookup tables. PowerShell already provides a cmdlet to do this for you. Have a look:
function Get-ServiceProcessId
{
[CmdletBinding()]
param
(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[string] $Name
)
begin
{
$hash = Get-CimInstance Win32_Service -ErrorAction Ignore |
Group-Object -Property Name -AsHashTable -AsString
}
process
{
$id = $hash.$Name.ProcessId
if ($id) { $id }
}
}
This function is blindingly fast: when you query multiple services, your WMI query runs only once. After the initial delay caused by the query, the requested PIDs are dumped without repeated delays.
This is because we added a begin {} block to the function. This part gets executed only once, for preparation, before anything else runs. In this block, we are querying WMI for all services which doesn’t really take any more time than asking for just one service.
In order to later find the information for a particular service, the query results are passed on to Group-Object. This cmdlet is perfect for building lookup tables because it supports the parameters -AsHashtable -AsString which automatically create a hashvtable that is indexed by the property you specified. Sounds complex? It isn’t. Have a look:
PS C:\> $hashtable = Get-CimInstance Win32_Service | Group-Object -Property Name -AsHashTable -AsString
PS C:\> $hashtable.Spooler.processId
4784
PS C:\> $hashtable.WSearch.ProcessId
10152
PS C:\> $hashtable.WSearch
ProcessId Name StartMode State Status ExitCode
--------- ---- --------- ----- ------ --------
10152 WSearch Auto Running OK 0
With the hash table generated by Group-Object, the key names are now the service names. When you specify a particular service name, you get the service object. When you add a specific property, i.e. .ProcessId, you directly get its process ID.
Let’s do another example so it sinks in before we move on:
PS C:\> $hashtable = Get-Service | Group-Object -Property Status -AsHashTable -AsString
PS C:\> $hashtable.Running.Count
156
PS C:\> $hashtable.Stopped.Count
170
PS C:\> $hashtable.Running
Status Name DisplayName
------ ---- -----------
Running AdobeARMservice Adobe Acrobat Update Service
Running AppIDSvc Application Identity
Running Appinfo Application Information
(...)
This time, we grouped by the property “Status”, and we can now use any of the Status constants to query the hash table. Since there is more than one service that has the status “Running” or “Stopped”, this time each key of the hash table returns more than one service. It is now trivial to count the number of running or stopped services, or dump all running services. The concept of grouping with hash tables is very flexible and extremely powerful.
However, when you think about this for a moment, it raises another challenge: now that you can scale your function and quickly retrieve the process IDs for many services, how much use is a list of numbers of the original service name is lost that a particular process ID belongs to?
Appending information
To append – rather than replace – information, PowerShell has yet another cmdlet exactly for this purpose. This improved version can append the process ID with its new parameter -PassThru:
function Get-ServiceProcessId {
[CmdletBinding()]
param
(
[Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ServiceName')]
[string]
$Name,
[Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ActualService')]
[System.ServiceProcess.ServiceController]
$Service,
[switch]
$PassThru
)
begin
{
$hash = Get-CimInstance Win32_Service -ErrorAction Ignore |
Group-Object -Property Name -AsHashTable -AsString
}
process
{
# since the user now has the option to either specify the service name
# or pipe in an actual service, let's look up the pendant so we always
# have both and can simplify the remaining code:
if ($PSCmdlet.ParameterSetName -eq 'ServiceName')
{
$Service = Get-Service -Name $Name -ErrorAction Ignore
}
else
{
$Name = $Service.Name
}
# make sure "0" values are replaced by NULL
$id = $hash.$Name.ProcessId | Where-Object { $_ -gt 0 }
if ($PassThru)
{
# take the service and add the process ID
$Service |
Add-Member -MemberType NoteProperty -Name ProcessId -Value $id -PassThru
}
else
{
$id
}
}
}
You can find the -PassThru parameter on quite a few PowerShell cmdlets, and when it is present, you know you have the choice to either output a newly calculated value, or append the new value to the original input. This is best understood when looking at the results. By default, nothing changed, and you continue to get the service process IDs:
PS C:\> Get-Service | Get-ServiceProcessId
5524
1960
(...)
If you specify -PassThru, you get the original input objects (the services you piped in):
PS C:\> Get-Service | Get-ServiceProcessId -PassThru
Status Name DisplayName
------ ---- -----------
Stopped AarSvc_78962bb Agent Activation Runtime_78962bb
Running AdobeARMservice Adobe Acrobat Update Service
Stopped ADPSvc Aggregated Data Platform Service
(...)
However, these objects now have a new property named ProcessId which you can use for sorting. To make it visible, use Select-Object:
PS C:\> Get-Service | Get-ServiceProcessId -PassThru | Sort-Object ProcessId -Descending | Select-Object -Property DisplayName, ProcessId
DisplayName ProcessId
----------- ---------
Web Threat Defense Service 19084
Diagnostic System Host 18580
Benutzerdienst für die Plattform für verbundene Ge... 17608
WebClient 16616
Quality Windows Audio Video Experience 15328
(...)
And here is how it works:
For a function to be able to pass through information, it needs access to the original input object. This is why the new function now has two parameters that are mutually exclusive: -Name (specify a service name like “Spooler”), and -Service (provide an actual instance of a service). Internally, the function detects which information the user provided, and calculates the missing.
When the user specifies -PassThru, the service object is piped to Add-Member. Add-Member can dynamically add new properties to any object. In this example, it adds a new “NoteProperty” (static value) named ProcessId and stores the newly retrieved process ID in this property. That’s all to it.
Once you embrace this technique, you can design your code in a much more modular way as we will discover next.
Flexible Lego-like System
For practicing, let’s add two more functions that find out the process name for a given process ID, and the service group name. It is the same basic approach using the same ingredients we just discussed.
Adding Process Name
Here is a modular PowerShell function that retrieves the clear-text process name from any object that has a property named ProcessId or Id of type integer:
function Get-ProcessNameById {
[CmdletBinding()]
param
(
[Parameter(Mandatory, ValueFromPipeline)]
[Object]
$InputObject,
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[int]
[Alias('Id')]
$ProcessId,
[switch]
$PassThru
)
process
{
$ProcessName = if ($ProcessId)
{
(Get-Process -Id $ProcessId).Name
}
if ($PassThru)
{
$InputObject |
Add-Member -MemberType NoteProperty -Name ProcessName -Value $ProcessName -PassThru
}
else
{
$ProcessName
}
}
}
Let’s quickly review the PowerShell coding tricks used here:
- The
param()block references the input object twice: it surfaces unchanged in$InputObject(so it can be passed on later if requested), and its process ID surfaces in$ProcessId. PowerShell does the hard part and identifies the appropriate property. You just specify the desired type ([int]in this example) and valid names (ProcessId,Id).[Alias()]accepts a comma-separated list, so you can specify as many property names as you like. Any of them are acceptable for this parameter, and the first wins. - If there was a process ID found in the input object, then it is found in
$ProcessId. The code uses a simpleIfto translate it to the process name. If$ProcessIdis NULL however (because the property was missing from the input object or was empty),$ProcessNameis NULL. The trick here is to assign$ProcessNametoIf, and not to assign it inside theIfclause, so it gets nulled when theIfcondition is not met. Look how beautifully and generically the new function can be used. You can use it with any input object that has aProcessIdorIdproperty. For example, find out the name of your current PowerShell host:
PS C:\> Get-Process -id $pid | Get-ProcessNameById
powershell_ise
Or, apply it to our services, and get a list of services, their underlying processes, and process IDs:
PS C:\> Get-Service | Get-ServiceProcessId -PassThru | Get-ProcessNameById -PassThru | Where-Object ProcessId | Select-Object -Property Name, ProcessName, ProcessId | select -First 5
Name ProcessName ProcessId
---- ----------- ---------
AdobeARMservice armsvc 5524
AppIDSvc svchost 1960
Appinfo svchost 13852
AudioEndpointBuilder svchost 4324
Audiosrv svchost 4456
(...)
Service groups
As you may have noticed, many services share the same process, and there are many processes with identical names (“svchost”). That’s by design, in Windows many services can run inside the same DLL.
To retrieve their service group, we can re-use Get-ServiceProcessId. This time, we are not reading the process ID from the WMI objects, but rather PathName:
function Get-ServiceGroupName
{
[CmdletBinding()]
param
(
[Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ServiceName')]
[string]
$Name,
[Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ActualService')]
[System.ServiceProcess.ServiceController]
$Service,
[switch]
$PassThru
)
begin
{
$hash = Get-CimInstance Win32_Service |
Group-Object -Property Name -AsHashTable -AsString
}
process
{
# since the user now has the option to either specify the service name
# or pipe in an actual service, let's look up the pendant so we always
# have both and can simplify the remaining code
if ($PSCmdlet.ParameterSetName -eq 'ServiceName')
{
$Service = Get-Service -Name $Name -ErrorAction Ignore
}
else
{
$Name = $Service.Name
}
# get the launch command for this service
$pathName = $hash.$Name.PathName
# look if the parameter "-k" was specified, followed by the service group name
# we are after (uses RegEx)
$groupName = if($pathName -match '-k\s+(\w+)')
{
$matches[1]
}
if ($PassThru)
{
# take the service and add the process ID
$Service |
Add-Member -MemberType NoteProperty -Name GroupName -Value $groupName -PassThru
}
else
{
$groupName
}
}
}
And since we used modular pipeline-aware scripting, we get yet another Lego-like piece of code that we can combine with the others as needed:
PS C:\> Get-Service | Get-ServiceProcessId -PassThru | Get-ProcessNameById -PassThru | Get-ServiceGroupName -PassThru | Where-Object ProcessId | Select-Object -Property Name, ProcessName, GroupName, ProcessId
Name ProcessName GroupName ProcessId
---- ----------- --------- ---------
AdobeARMservice armsvc 5524
AppIDSvc svchost LocalServiceNetworkRestricted 1960
Appinfo svchost netsvcs 13852
AudioEndpointBuilder svchost LocalSystemNetworkRestricted 4324
Audiosrv svchost LocalServiceNetworkRestricted 4456
(...)
PowerShell Universal to the rescue
You may have invested a couple of hours of work and created great PowerShell scripts that get you the information you need. But will you remember all of this next week? Next month? What if you just need this information in production? Or you want a help desk colleague to view it without having to introduce them to consoles and PowerShell scripts?
That’s where PowerShell Universal comes in. It is completely free for personal use, and if you want to use it commercially, it is economically licensed per server, not per seat.
In a nutshell, PowerShell Universal embeds a PowerShell host in a webserver. This enables you to safely build GUI-rich dashboards, turn scripts into services and web services, and much more.
For now, we want to use the free personal edition to build a rich GUI dashboard where we can look up the service information we just retrieved. Keep in mind that this was just an example, obviously you can replace our code with anything that matters more to you, and display VMWare servers, picture collections, or user accounts in the same way.
Setting up PSU
Here are the few steps it takes to set up PSU on your own machine. No license, no cost, no Admin privileges required. You can use the PowerShell Universal module to install the Universal server. To install the module, use Install-Module or Install-PSResource.
Install-Module Devolutions.PowerShellUniversal
To install the Universal server, you can use Install-PSUServer.
Install-PSUServer -LatestVersion
Running this command has different effects depending on your operating system:
- On Windows, it creates and starts a Windows service on your machine.
- On Linux, it creates and starts a systemd service.
- On macOS, it downloads and extracts the PowerShell Universal server. For additional installation options, please refer to the documentation at Installation | PowerShell Universal .
Creating a PSU app
To add a GUI to our script, navigate to http://localhost:5000 to open your PSU management console. You need to log in using the Devolutions account you created earlier.
On the left side, click “Apps”, then “App”. On the right side, you now see a list of apps you created earlier. Initially, this list is empty.
Click “Create App”. Give it a friendly name, i.e. “Service Dashboard”. In the field below, enter the “URL” for your app. This needs to be a relative name, prefixed by a forward slash “/”, for example “/list-services”. You can leave “Description” empty. Make sure the “Definition” slider shows “Code”, not “Command”. Then click OK. The new app shows in your list of apps.
Next, click the pencil icon. This opens the integrated PowerShell editor. The default code is this:
New-UDApp -Content { 'Hello, World!' }
To test-run this app, click the button “View App” on the top. Your web-based PowerShell script runs, and you see a friendly message in your browser. You also see how you can access this app: its URL is http://localhost:5000/list-services/Home.
Caveats
Before we move on, here are two important things to remember:
- The default example code uses
New-UDApp {}to show a simple message. This cmdlet must always be present, it creates the app. All of your adjustments take place inside the braces only. - You can always test-drive your code by clicking the button “View App” on top of the editor page, but you must click the SAVE icon first. “View App” does not automatically save your latest edits. If you forget to click SAVE, “View App” ignores your latest code edits.
Displaying a Table
Replace the default code inside the braces with this code:
New-UDApp -Content {
# 1.) prepare the data to show
# get all services...
$data = Get-Service -ErrorAction Ignore |
Sort-Object -Property DisplayName |
# ...select the properties to display in the table
Select-Object -Property DisplayName, Name, StartMode, StartType
# 2.) output it using one of the many available UI components, i.e. as a table
New-UDTable -Data $data
}
The code retrieves some information and stores it in $data. Then, it calls one of the many UD…-cmdlets to display the data in your web app. New-UDTable, for example, automatically creates a table for you.
Once you click “View App”, you can display the table in your browser:
Maybe you have noticed that we added -ErrorAction Ignore to our code. That’s because by default, PSU is running your code in PowerShell 7. PowerShell 7 behaves slightly different than Windows PowerShell, and Get-Service may emit exceptions on non-US systems. Whenever exceptions are raised in your code, the app would show these error messages. To avoid this, always make sure your code isn’t emitting unhandled exceptions.
Service viewer
Now that you have seen how simple it is to add a graphical browser-based user interface to your code, let’s wrap everything up and create our extended service process viewer. Replace the code with the code we created earlier:
# first, define the functions we created
# these functions could also be stored in external modules so they are
# out of the way, but for now we keep everything as a single script
# submit name of service or pipe in services
# adds the process ID of the process that executes a service
function Get-ServiceProcessId {
[CmdletBinding()]
param
(
[Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ServiceName')]
[string]
$Name,
[Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ActualService')]
[System.ServiceProcess.ServiceController]
$Service,
[switch]
$PassThru
)
begin
{
$hash = Get-CimInstance Win32_Service -ErrorAction Ignore |
Group-Object -Property Name -AsHashTable -AsString
}
process
{
# since the user now has the option to either specify the service name
# or pipe in an actual service, let's look up the pendant so we always
# have both and can simplify the remaining code
if ($PSCmdlet.ParameterSetName -eq 'ServiceName')
{
$Service = Get-Service -Name $Name -ErrorAction Ignore
}
else
{
$Name = $Service.Name
}
# make sure "0" values are replaced by NULL
$id = $hash.$Name.ProcessId | Where-Object { $_ -gt 0 }
if ($PassThru)
{
# take the service and add the process ID
$Service |
Add-Member -MemberType NoteProperty -Name ProcessId -Value $id -PassThru
}
else
{
$id
}
}
}
# pipe in any object with a property "ProcessId" or "Id", and add a property with
# the actual process name
function Get-ProcessNameById {
[CmdletBinding()]
param
(
[Parameter(Mandatory, ValueFromPipeline)]
[Object]
$InputObject,
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[int]
[Alias('Id')]
$ProcessId,
[switch]
$PassThru
)
process
{
$ProcessName = if ($ProcessId)
{
(Get-Process -Id $ProcessId).Name
}
if ($PassThru)
{
$InputObject |
Add-Member -MemberType NoteProperty -Name ProcessName -Value $ProcessName -PassThru
}
else
{
$ProcessName
}
}
}
# submit the name of a service, or pipe in services
# adds the property "GroupName", exposing the name of the service group for
# the given service
function Get-ServiceGroupName
{
[CmdletBinding()]
param
(
[Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ServiceName')]
[string]
$Name,
[Parameter(Mandatory, ValueFromPipeline, ParameterSetName='ActualService')]
[System.ServiceProcess.ServiceController]
$Service,
[switch]
$PassThru
)
begin
{
$hash = Get-CimInstance Win32_Service |
Group-Object -Property Name -AsHashTable -AsString
}
process
{
# since the user now has the option to either specify the service name
# or pipe in an actual service, let's look up the pendant so we always
# have both and can simplify the remaining code
if ($PSCmdlet.ParameterSetName -eq 'ServiceName')
{
$Service = Get-Service -Name $Name -ErrorAction Ignore
}
else
{
$Name = $Service.Name
}
# get the launch command for this service
$pathName = $hash.$Name.PathName
# look if the parameter "-k" was specified, followed by the service group name
# we are after (uses RegEx)
$groupName = if($pathName -match '-k\s+(\w+)')
{
$matches[1]
}
if ($PassThru)
{
# take the service and add the process ID
$Service |
Add-Member -MemberType NoteProperty -Name GroupName -Value $groupName -PassThru
}
else
{
$groupName
}
}
}
# NOW we are defining our web app
# thanks to our modular functions, this part is short and simple to
# understand and modify
New-UDApp -Content {
# 1.) prepare the data to show
# retrieve all services (make sure to catch any error)
$data = Get-Service -ErrorAction Ignore |
# ...add the service process ID...
Get-ServiceProcessId -PassThru |
# ...take only services that have a process ID...
Where-Object ProcessId |
# ...add the process name for the process ID...
Get-ProcessNameById -PassThru |
# ...add the service group name so we can differentiate svchost...
Get-ServiceGroupName -PassThru |
# ...sort by service displayname...
Sort-Object -Property DisplayName |
# select the properties to display in the table
Select-Object -Property DisplayName, Name, ProcessName, GroupName, ProcessId
# 2.) output it using one of the many available UI components, i.e. as a table
New-UDTable -Data $data
}
At the beginning, you find the three functions we created earlier. In a later part of this series, we’ll move these functions out of the way and put them in external modules, but for now, everything is defined in the code so we don’t have any dependencies.
Then, the retrieved data is displayed by the same New-UDTable you used earlier. When you click “View App”, your code executes, the service information is retrieved, and a table presents the data to you.
All you need is the URL for this web app: http://localhost:5000/list-services/Home. You could save it as a link on your desktop or bookmark it in your browser. Whenever you need service information, you no longer need to search for scripts and execute them in a console.
And when you let this sink in, you’ll see the tremendous opportunities: you can build your personal dashboards. But with a corporate license and by setting PSU up with Administrator privileges, you could as well host your scripts as a service, providing browser-based access with granular security control to anyone in your organization.
We’ll get there throughout this series. For now, you have seen how to set up PSU and create your first own PSU web app. In the next part, we’ll again start with PowerShell code and then extend the dashboard with new controls and more capabilities. We’ll also explain why the service table that you just created shows the columns in a somewhat unexpected order, and how to correct it.

Aleksandar Nikolić