Generating Reports With PowerShell, Part 4: Generating A Visual Server Health Dashboard

My last three posts have described ways of collecting information and building reports using PowerShell in a variety of circumstances. In this post, I’ll be describing a revised and expanded version of the Active Directory health report I described in part 1. When I say “expanded”, I mean that this script’s scope is broader than the previous script – this script incorporates information on CPU & memory load, available space on the system drive, LDAP response times and AD replication status for domain controllers, rather than focusing exclusively on the Active Directory status itself. Our Exchange server status is also included for the non-AD performance aspects.

For the script to work, the following are required:

  • It must run under an account with domain admin rights (required for repadmin) and administrator access to the relevant servers.
  • The local system must have the ActiveDirectory PS module and AD DS Tools installed.
  • The local system must have .NET 3.5 installed.
  • The local system must have the Microsoft Chart Controls for Microsoft .NET Framework 3.5 installed.

The following pages were particularly helpful for me when assembling this script:

One last thing to note – the scripts below may be difficult to read as they appear here because some sections use strings to assemble HTML, and the WordPress code is automatically converting the less-than symbol to


and the greater-than symbol to


For legibility it may be easier to copy the script segments into your text editor of choice and ctrl-f the correct symbols back into place. Alternatively, I have uploaded a sanitised version of the entire script – a lot of this post is taken verbatim from the script comments, so it should be quite easy to follow. Note that due to WordPress restrictions, I have had to change the file extension to .doc.

To give you an idea of what it looks like, here’s a screenshot of the end result, with server names blurred out for obvious reasons:

With that being said, let’s get started!

Import-Module ActiveDirectory
$allservers = @()
# Manually prepopulate the serverlist array with Exchange servers.
$serverlist="[Exchange Server 1]",[...],"[Exchange Server X]"
# Search for all domain controllers in the current domain and add them to the serverlist.
foreach ($s in (Get-ADDomainController -Filter *).Name) {
# Sort the server list alphabetically.
$serverlist = $serverlist | Sort-Object

First, the AD module is imported. Next, an array is defined to hold information about all servers of interest, and a variable is declared to capture the current date & time.

Next comes a manually-populated array of all Exchange servers in the environment to be monitored. Ideally, this would be programmatically defined – unfortunately, the environment in which this script is used contains a significant number of systems which have all Exchange services installed, despite not being in use as production Exchange servers. If the target environment features a consistent naming convention that can exclusively identify the production Exchange servers, this array could be procedurally generated as the output of a Get-ADComputer query using the Filter switch and applying a suitable filter based on the Name attribute.

After manually adding the Exchange servers to the serverlist array, Get-ADDomainController is used to query the domain for all available DCs. The Name attribute is selected, and each name is added in turn to the serverlist array.

Finally, the serverlist array is sorted alphabetically.

$User="[Common username prefix]"+(Get-Random -Maximum 9999)

foreach ($s in $serverlist) {
	$Obj=New-Object System.Object
	$Obj | Add-Member -MemberType NoteProperty -Name ServerName -value $s -Force

	$CPU="{0:N2}" -f (Get-Counter -Computername $s -Counter '\Processor(_Total)\% Processor Time').CounterSamples.CookedValue
	$Obj | Add-Member -MemberType NoteProperty -Name CPU -value $CPU -Force
	$avail=(Get-Counter -Computername $s -Counter '\Memory\Available MBytes').CounterSamples.CookedValue
	$total=((Get-WMIObject -Computername $s -class Win32_Computersystem).TotalPhysicalMemory/(1024*1024))
	$usage="{0:N2}" -f (($total-$avail)/$total)*100

	$Obj | Add-Member -MemberType NoteProperty -Name RAMUsed -value $usage -Force

	$freespace="{0:N2}" -f ((Get-WMIObject -computername $s -query "Select * from Win32_logicaldisk where Drivetype='3' and DeviceID='C:'").FreeSpace/(1024*1024*1024))
	$Obj | Add-Member -MemberType NoteProperty -Name Freespace -value $freespace -Force
	$pingms="{0:N2}" -f ((Test-Connection $s -Count 4) | Measure-Object -Property ResponseTime -Average).Average
	$Obj | Add-Member -MemberType NoteProperty -Name PingTime -value $pingms -Force
	$dc= (Get-ADdomainController -filter *).Name | Where-Object {$_ -like $s}
	if ($dc) {
		$string="LDAP://"+$dc+"."+$($env:UserDNSDomain -replace ("<DOMAIN NAME>.",""))
		$Searcher = New-Object System.DirectoryServices.DirectorySearcher $Root
		$Searcher.Filter = "(cn=$User)"
		$ldapms= ((Measure-Command {$Container = $Searcher.FindAll()}).TotalMilliseconds)
		$Obj | Add-Member -MemberType NoteProperty -Name LDAPTime -value $ldapms -Force
		Remove-Variable -Name dc,string,root,searcher,ldapms -Force -ErrorAction SilentlyContinue
	} else {
		$Obj | Add-Member -MemberType NoteProperty -Name LDAPTime -value $null -Force

	$allservers += $Obj
	Remove-Variable -Name Obj,CPU,RAM,avail,total,usage,freespace,pingms -Force -ErrorAction SilentlyContinue

First, a username is defined to be used for LDAP lookup timing tests. Randomly selecting a user from the target AD environment will add ~20 seconds to the script’s runtime, so as a compromise a random but feasible user ID is generated here and used for the LDAP lookup. The user account does not need to exist in order to be used for the search.

For each server in the serverlist, this loop will create a new object, query the server for a variety of values and counters, and add them as properties on the object. Certain values are only checked for domain controllers.

CPU usage is queried from the CPU usage counter, and the CookedValue is used because it is supplied as a percentage value. The use of “{0:N2}” -f when declaring the variable value ensures that we get the value to 2 decimal places.

RAM usage is slightly trickier, as there no counter for the percentage of memory in use. However, a counter for available (i.e. free) RAM is available, and a WMI query can retrieve the total system RAM. Note that this value must be divided by 1024*1024 to convert it from the returned byte value to the desired megabyte value. Both of these values are used in conjunction to work out the percentage memory usage. Again, the use of “{0:N2}” -f when declaring the variable ensures that we get each value to 2 decimal places.

Free space on the system disk is queried via WMI, using DriveType and DeviceID filters. DriveType 3 ensures that the drive being checked is a fixed internal drive. Using “C:” as the filter for the DeviceID is imperfect, as it is not guaranteed that this will be the systemdrive for all systems, but it is an acceptable initial configuration since C: is the default system drive for a standard Windows Server installation.

The average ping response time in milliseconds is found by Measure-Object cmdlet to take the average value of the response time for 4 pings to the server.

For domain controllers only, the LDAP response time in milliseconds is found by defining and executing an LDAP search. The elapsed time in milliseconds for the search is gathered using the Measure-Command cmdlet. For non-DC servers, a value of null is assigned to the object property.

After all the raw data is gathered for the server, the server object is added to the allservers array declared at the start of the script. All variables used in the data collection are then forcibly removed.

$repinfo=repadmin /replsummary
<table border=`"0`" cellpading=`"2`" style=`"float: center`">"
<table border=`"0`" cellpading=`"2`" style=`"float: center`">"

<th bgcolor=`"#ADDFFF`">Source DSA</th>
<th bgcolor=`"#ADDFFF`">Largest Delta</th>
<th bgcolor=`"#ADDFFF`">Fails/Total</th>

<th bgcolor=`"#ADDFFF`">Destination DSA</th>
<th bgcolor=`"#ADDFFF`">Largest Delta</th>
<th bgcolor=`"#ADDFFF`">Fails/Total</th>


foreach ($line in $repinfo) {
	switch -regex ($line)	{
		"^Destination" {
		"^*DC-[09]" {
			foreach ($r in ($line -split "    ")) {
				if ($r -match '\S+') {
					while ($r.Contains(" ")) {
						$r = $r -replace " ",""
			# DC Name

			# Largest Delta

			# Error count
			if ($dcrepinfo[2] -match "^0") {
			} else {
<td bgcolor=`"$bg`" align=`"center`">$($dcrepinfo[2])</td>


			if (!$dest) {
			} else {
# After parsing all lines in repinfo, add the end-of-table HTML.



DC replication summary data is gathered as text using repadmin /replsummary. This is then parsed on a line-by-line basis, using a switch statement with regular expression matching to assemble two strings. Each string contains the HTML to display a table of the collected values. This is done to allow for easy visual representation of the Source DSA and Destination DSA details.

The switch matches two values using regular expression matching:
“^Destination:” (a line starting with the word “Destination:”) This value only appears once in the output. Its appearance indicates that the Source DSA table is complete and the Destination DSA table needs to be started. Thus the action taken in this section is to initiate the Destination DSA table.

“^DC-[09]” If the line starts with any text followed by “DC-” and two digits, it needs to be parsed. Parse info for domain controller. The line is split using a 4-space separator. Each fragment remaining is then checked for non-space content. Non-empty fragments are then iterated until all spaces have been removed, and then each fragment is added to the $dcrepinfo array. HTML table code is then added to the string for each value; the error count is used to determine the background colour of the relevant cell to add visual emphasis.







foreach ($s in $allservers) {
	if ($s.LDAPTime) {
	} else {
foreach ($p in $DCPingTotal) {
	switch ($p) {
		{$_ -lt 1} {
		{($_ -ge 1) -and ($_ -lt 5)} {
		{$_ -gt 5} {

$LDAPTotal=$LDAPTotal | Sort-Object
$LDAPAvg="{0:N4}" -f ($LDAPTotal | Measure-Object -Average).Average
$LDAPmax=$LDAPTOTAL[$($LDAPTotal.count -1)]

[int]$ExPingcount=$allservers.count - $DCPingCount
foreach ($p in $ExchPingTotal) {
	switch ($p) {
		{$_ -lt 1} {
		{($_ -ge 1) -and ($_ -lt 5)} {
		{$_ -gt 5} {

foreach ($c in $CPUTotal) {
	switch ($c) {
		{$_ -le 60} {
		{($_ -gt 60) -and ($_ -le 80)} {
		{$_ -gt 80} {


foreach ($r in $RAMTotal) {
    switch ($r) {
		{$_ -le 60} {
		{($_ -gt 60) -and ($_ -le 80)} {
		{$_ -gt 80} {

foreach ($f in $FreespaceTotal) {
	switch ($f) {
		{$_ -ge 10} {
		{($_ -ge 2) -and ($_ -lt 10)} {
		{$_ -lt 2} {


foreach ($r in $reperr) {
	$errs=$errs+($r -split "/")[0]
	$count=$count+($r -split "/")[1]

This section processes the server object values into information that can be used to generate charts. To start with, individual arrays are declared for each property being checked. Following this, the arrays are populated with the values for each server. If an object has a non-null value assigned to LDAPTime it is treated as a DC, otherwise it’s treated as an Exchange server.

After all arrays have been populated, each array is used to define the chart values.

For the pie charts, an integer is declared for each category that the chart will feature. All but one of the pie charts uses three values, on a traffic-light colour system.

DC ping response times: Each server’s ping time is checked and the relevant integer incremented accordingly. The categories in use are “less than 1ms” (Green), “1-5ms” (Amber) and “>5ms” (Red).

LDAP response times: these are handled differently to other values. The initial LDAP Response Times chart used in this report does not require any processing of the information already in the array. However, the minimum, maximum and average values are declared here so that they can be added to the chart (or to a separate chart) in future if required.

Exchange response times: Each server’s ping time is checked and the relevant integer incremented accordingly. The categories in use are “less than 1ms” (Green), “1-5ms” (Amber) and “>5ms” (Red).

CPU chart: Three usage thresholds are defined as “0-60%” (Green), “61-80%” (Amber), and “81-100%” (Red). The integer for each category is incremented according to each server’s CPU utilisation.

RAM chart: Three usage thresholds are defined as “0-60%” (Green), “61-80%” (Amber), and “81-100%” (Red). The integer for each category is incremented according to each server’s RAM utilisation.

Free space chart: Three thresholds are defined as “>10GB free” (Green), “2-10GB free” (Amber), and ”

Replication status chart: This is a binary chart, so only two variables are used, Pass (Green) or Fail (Red).

The integer values for each chart are then converted into percentages through division by the array count, and the percentage values are used when generating the charts themselves.


$DCChart=New-Object System.Windows.Forms.DataVisualization.Charting.Chart
$DCChartArea=New-Object System.Windows.Forms.DataVisualization.Charting.ChartArea

[void]$DCChart.Titles.Add("DC Ping Times")
$DCChart.Titles[0].Font="Arial Black,24pt"


$datapoint=New-Object System.Windows.Forms.DataVisualization.Charting.DataPoint(0,$DCGreen)
if ($DCgreen -lt 100) {
$label="{0:N0}" -f ($DCGreen)
$Datapoint.LegendText="< 1 ms" $DCChart.Series["Data"].Points.Add($datapoint) $datapoint=New-Object System.Windows.Forms.DataVisualization.Charting.DataPoint(0,$DCYellow) $datapoint.Color="Yellow" if ($DCYellow -gt 0) { $label="{0:N0}" -f ($DCYellow) $Datapoint.Label="$($label)%" $label=$null } $Datapoint.LegendText="1 - 5 ms" $DCChart.Series["Data"].Points.Add($datapoint) $datapoint=New-Object System.Windows.Forms.DataVisualization.Charting.DataPoint(0,$DCRed) $Datapoint.Color="Red" if ($DCRed -gt 0) { $label="{0:N0}" -f ($DCRed) $Datapoint.Label="$($label)%" $label=$null } $datapoint.LegendText="> 5 ms"

$DClegend=New-Object System.Windows.Forms.DataVisualization.Charting.Legend
$DClegend.Name="DC Ping Times"
$DClegend.Font="Arial Black"

$DCChart.Series["Data"].ChartType = [System.Windows.Forms.DataVisualization.Charting.SeriesChartType]::Pie
$DCChart.Series["Data"]["PieLabelStyle"] = "Outside"
$DCChart.Series["Data"]["PieLineColor"] = "Black"
$DCChart.Series["Data"]["PieDrawingStyle"] = "Concave" 

$DCChart.SaveImage("<file path>\<file name>.png","PNG")

Remove-Variable -Name DCChart,DCChartArea,datapoint,dclegend -Force -ErrorAction SilentlyContinue

The next section handles the data presentation & assembling the charts. The first step is to load the relevant .NET assemblies.

Several chart types are available, but only two are used in this report – pie and column charts. Accordingly, one chart of each type will be described, since the remaining charts are generated using the same approach. The first chart shown is a pie chart.

To start with, the chart itself is declared. After doing this size and location parameters can be defined. A Chart Area must also be defined separately that will contain the chart visualisation. A chart title can also be assigned, and parameters such as location, font face and font size can be defined.

Next, a new data series is defined in the chart. For each datapoint, a datapoint object is created. This can then be assigned attributes like colour, labels and legend text. For visual clarity, each datapoint assignment uses an if-check before assigning the labels – where a chart is 100% green no label is required, but where the yellow or red values are non-zero, a label is assigned with the percentage value for each segment. Once prepared, each datapoint is added to the data series for the chart.

After the datapoints have been defined, the legend is defined. The font face for the legend can be defined here, alongside other attributes if desired. Once the legend is prepared, it is added to the chart.

The chart is almost ready – the chart type is now defined, along with some other visualisation attributes such as the line style and label placement. After this is done, the chart is saved as an image file using the Chart SaveImage() method. This allows specification of the image location and type. In this report the PNG format is used.

Lastly, the variables used while creating the chart are cleaned up.

$LDAPChart=New-Object System.Windows.Forms.DataVisualization.Charting.Chart
$LDAPChartArea=New-Object System.Windows.Forms.DataVisualization.Charting.ChartArea
$LDAPchartarea.AxisY.Title = "Response time (ms)"

$LDAPchartarea.AxisY.Interval = [decimal]::ceiling(($LDAPData.Values | Measure-Object -Maximum).Maximum)

[void]$LDAPChart.Titles.Add("LDAP Query Time")
$LDAPChart.Titles[0].Font="Arial Black,24pt"

$LDAPlegend=New-Object System.Windows.Forms.DataVisualization.Charting.Legend
$LDAPlegend.Name="LDAP Query Times"
$LDAPlegend.Font="Arial Black"

foreach ($a in $allservers) {
	if ($a.LDAPtime) {
$LDAPChart.Series["Data"].Points.DataBindXY($LDAPData.Keys, $LDAPData.Values)

foreach ($p in $LDAPChart.Series["Data"].Points) {

$LDAPChart.Series["Data"].ChartType = [System.Windows.Forms.DataVisualization.Charting.SeriesChartType]::Column
$LDAPChart.Series["Data"]["ColumnLineColor"] = "Black"


Remove-Variable -Name LDAPChart,LDAPChartArea,datapoint -Force -ErrorAction SilentlyContinue

AT first, the column chart is created in the same way as the pie chart. Initially, the core “LDAP Query Time” options are defined. These settings are largely the same as for pie charts in terms of the overall chart dimensions, but with some custom settings for the column chart. For example the Y-axis gets a title of its own, and the Y-axis interval is configured as a function of the largest value in the dataset so that deviations from the norm are more visibly apparent.

Next, the chart title and legend are defined, in much the same way as they are for the pie charts.

Unlike the pie charts, for the column chart each data point is added as a hashtable – this allows each server name to be listed along the X-xis. This in turn means that persistent problems can be quickly identified. To make the chart easier to read in visual terms, each column is assigned a different colour. The method used here is the first idea I had that would work; the Chart Controls actually offer a much wider range of colors than the default console colors, so another approach might be a variation on using $colours=[enum]::GetValues([System.ConsoleColor]) as the $colours array definition. While writing the script I didn’t want to get sidetracked onto something trivial, so I stuck with the simple approach shown above.

After this the chart type and appearance settings are assigned, then the chart is saved as a file, and the variables used are cleaned up.


&amp;lt;table border="0" cellpadding="2" style="float: center" style="width:100%"&amp;gt;

&amp;lt;tr style="height:300px"&amp;gt;

&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="DCResponseTime.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;

&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="LDAPTime.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;

&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="ExResponseTime.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;


&amp;lt;tr style="height:300px"&amp;gt;

&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="CpuUsage.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;

&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="RamUsage.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;

&amp;lt;td style="width:500px"&amp;gt;&amp;lt;img src="FreespaceUsage.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;





&amp;lt;table border="0" cellpadding="2" style="float: center" style="width:100%"&amp;gt;


&amp;lt;td style="width:500px" align="center"&amp;gt;$($sourcetable)&amp;lt;/td&amp;gt;

&amp;lt;td style="width:500px" align="center"&amp;gt;$($desttable)&amp;lt;/td&amp;gt;

&amp;lt;td style="width:500px" align="center"&amp;gt;&amp;lt;img src="Replication.png" align="middle"&amp;gt;&amp;lt;/td&amp;gt;




$timestamp=(Get-Date -format 'MM/dd HH:mm')

&amp;lt;font face=`"Arial Black`" size=`"6`"&amp;gt;&amp;lt;b&amp;gt;DC &amp;amp; Exchange Server Status:
Last refresh:`t$($timestamp)&amp;lt;/h1&amp;gt;


$HTML="&amp;lt;html&amp;gt;`n&amp;lt;head&amp;gt;" + "`n&amp;lt;/head&amp;gt;`n" + $header + "

" + $maintable + "`n" + $repltable + "


$HTML | Out-file -FilePath $outputfile

The final section deals with the comparatively simple task of assembling HTML for the report page. The tables are defined individually as here-strings, then a variable containing the full page HTML is declared and exported as a file.

In terms of the overall workflow, the images and report file are uploaded via FTP to the central server controlling the displays. A scheduled task runs on a set frequency to generate the output files, then run a batch file which opens an FTP connection to the server and uploads the new files. The displays are configured to refresh periodically and pull down the new files.

There are some things I’d refine if I ever revisit this script – amongst other things, for length alone I’d prefer to reuse a single method for generating all the pie-charts used, but again the initial form works well enough that it wasn’t a requirement – particularly since this report is intended only as an interim solution. I would also like to refine the overall output so that average, maximum and minimum values could be displayed underneath each chart. Similarly, I’d like to have some way of attaching server names to the labels for each pie chart, for example so that if one or more servers is showing >80% CPU usage they are identified by name on the chart.

A somewhat more involved and complex addition would be to enable tracking over time; a first approximation of this would probably involve adding code to output each server’s set of values to a file, and have a separate script run which can do a hour/day/week trend plot. This could be quite useful for performance visualisation but also starts to become complex enough to define and maintain that it may be easier to use a purpose-built tool to do it such as System Centre Operations Manager.


Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.