Getting Creative: a Bespoke Solution for Feature Update Deployments

This is the first blog post in what I hope will be a series of posts demonstrating several custom solutions I created for things such as feature update deployments, managing local admin password rotation, provisioning Windows 10 devices, managing drive mappings and more. My reasons for creating these solutions was to overcome some of the current limitations in existing products or processes, make things more cloud-first and independent of existing on-prem infrastructure where possible, and to more exactly meet the requirements of the business.

Although I will try to provide a generalised version of the source code where possible, I am not providing complete solutions that you can go ahead and use as is. Rather my intention is to inspire your own creativity, to give working examples of what could be done if you have the time and resource, and to provide source code as a reference or starting point for your own solutions should you wish to create them!

Someone asked me recently how we deploy feature updates and it was a difficult question to answer other than to say we use a custom-built process. Having used some of the existing methods available (ConfigMgr Software Updates, ConfigMgr custom WaaS process, Windows Update for Business) we concluded there were shortcomings in each of them, and this provided inspiration to create our own, customized process to give us the control, reliability, user experience and reporting capability that we desired. Don’t get me wrong – I am not saying these methods aren’t good – they just couldn’t do things exactly the way we wanted.

So I set out to create a bespoke process – one that we could customize according to our needs, that was largely independent of our existing Configuration Manager infrastructure and that could run on any device with internet access. This required making use of cloud services in Azure as well as a lot of custom scripting! In this blog, I’ll try to cover what I did and how it works.

User Experience

First, let’s look at the end user experience of the feature update installation process – this was something key for us, to improve the user experience keeping it simple yet informative, and able to respond appropriately to any upgrade issues.

Once the update is available to a device, a toast notification is displayed notifying the user that an update is available. Initially, this displays once a day and automatically dismisses after 25 seconds. (I’ve blanked out our corporate branding in all these images)

We use a soft deadline – ie the update is never forced on the user. Enforcing compliance is handled by user communications and involvement from our local technicians. With one week left before the deadline, we increase the frequency of the notifications to twice per day.

If the deadline has passed, we take a more aggressive approach with the notifications, modifying the image and text, displaying it every 30 minutes and it doesn’t leave the screen unless the user actions or dismisses it.

The update can be installed via a shortcut on the desktop, or in the last notification it can be initiated from the notification itself.

Once triggered, a custom UI is displayed introducing the user to the update and what to expect.

When the user clicks Begin, we check that a power adapter is connected and no removable USB devices are attached – if they are, we prompt to the user to remove them first.

The update runs in three phases or stages – these correspond to the PreDownload, Install and Finalize commands on the update (more on that later). The progress of each stage is polled from the registry, as is the Setup Phase and Setup SubPhase.

Note that the user cannot cancel the update once it starts and this window will remain on the screen and on top of all other windows until the update is complete. The user can click the Hide me button, and this will shrink the window like so:

This little window also cannot be removed from the screen, but it can be moved around and is small enough to be unobtrusive. When the update has finished installing, or when the user clicks Restore, the main window will automatically display again and report the result of the update.

The colour scheme is based on Google’s material design, by the way.

If the update failed during the online phase, the user can still initiate the update from the desktop shortcut but toast notifications will no longer display as reminders. The idea is that IT can attempt to remediate the device and run the update again after.

If successful, the user can click Restart to restart the computer immediately. Then the offline phase of the upgrade runs, where you see the usual light blue screen and white Windows update text reporting that updates are being installed.

Once complete, the user will be brought back to the login screen, and we won’t bother them anymore.

If the update rolled back during the offline phase, we will detect this next time they log in and notify them one time:

Logging and Reporting

The entire update process is logged right from the start to a log file on the local machine. We also send ‘status messages’ at key points during the process and these find their way to an Azure SQL database which becomes the source for reporting on update progress across the estate (more on this later).

A PowerBI report gives visual indicators of update progress as well as a good amount of detail from each machine including update status, whether it passed or failed readiness checks and if failed, why, whether it passed the compatibility assessment, if it failed the assessment or the install we give the error code, whether any hard blocks were found, setup diag results (2004 onward), how long the update took to install and a bunch of other stuff we find useful.

Since 2004 though, we have starting inventorying certain registry keys using ConfigMgr to give us visibility of devices that won’t upgrade because of a Safeguard hold or other reason, so we can target the upgrade only to devices that aren’t reporting any known compatibility issues.

If a device performs a rollback, we can get it to upload key logs and registry key dumps to an Azure storage account where an administrator can remotely diagnose the issue.

How does it work?

Now lets dive into the process in more technical detail.

Deployment Script

The update starts life with a simple PowerShell script that does the following:

  • Creates a local directory to use to cache content, scripts and logs etc
  • Temporarily stores some domain credentials in the registry of the local SYSTEM account as encrypted secure strings for accessing content from a ConfigMgr distribution point if necessary (more on this later)
  • Downloads a manifest file that contains a list of all files and file versions that need to be downloaded to run the update. These include scripts, dlls (for the UI), xml definition files for scheduled tasks etc
  • Each file is then downloaded to the cache directory from an Azure CDN
  • 3 scheduled tasks are then registered on the client:
    • A ‘preparer’ task which runs prerequisite actions
    • A ‘file updater’ task which keeps local files up-to-date in case we wish to change something
    • A ‘content cleanup’ task which is responsible for cleaning up in the event the device gets upgraded through any means
  • A ‘status message’ is then sent as an http request, creating a new record for the device in the Azure SQL database

This script can be deployed through any method you wish, including Configuration Manager, Intune or just manually, however it should be run in SYSTEM context.


All content needed for the update process to run is put into a container in a storage account in Azure. This storage account is exposed via an Azure Content Delivery Network (CDN). This means that clients can get all the content they need directly from an internet location with minimal latency no matter where they are in the world.

Feature Update Files

The files for the feature update itself are the ESD file and WindowsUpdateBox.exe that Windows Update uses. You can get these files from Windows Update, WSUS, or as in our case, from Configuration Manager via WSUS. We simply download the feature updates to a deployment package in ConfigMgr and grab the content from there.

You could of course use an ISO image and run setup.exe, but the ESD files are somewhat smaller in size and are sufficient for purpose.

The ESD files are put into the Azure CDN so the client can download them from there, but we also allow the client the option to get the FU content from a local ConfigMgr distribution point if they are connected to the corporate network locally. Having this option allows considerably quicker content download. Since IIS on the distribution points is not open to anonymous authentication, we use the domain credentials stamped to the registry to access the DP and download the content directly from IIS (credentials are cleaned from the registry after use).

Status Messages

Similar to how ConfigMgr sends status message to a management point, this solution also send status messages at key points during the process. This works by using Azure Event Grid to receive the message sent from the client as an http request. The Event Grid sends the message to an Azure Function, and the Azure Function is responsible to update the Azure SQL database created for this purpose with the current upgrade status of the device. The reason for doing it this way is that sending an http request to Event Grid is very quick and doesn’t hold up the process. Event Grid forwards the message to the Azure Function and can retry the message in the case it can’t get through immediately (although I’ve never experienced any failures or dead-lettering in practice). The Azure Function uses a Managed Identity to access the SQL database, which means the SQL database never needs to be exposed outside of its sandbox in Azure, and no credentials are needed to update the database.

We then use PowerBI to report on the data in the database to give visibility of where in the process every device is, if there are any issues that need addressing and all the stats that are useful for understanding whether devices get content from Azure or a local DP, what their approximate bandwidth is, how long downloads took, whether they were wired or wireless, make and model, upgrade time etc.

Preparation Script

After the initial deployment script has run, the entire upgrade process is driven by scheduled tasks on the client. The first task to run is the Preparation script and this attempts to run every hour until successful completion. This script does the following things:

  • Create the registry keys for the upgrade. These keys are stamped with the update progress and the results of the various actions such as pre-req checks, downloads etc. When we send a ‘status message’ we simply read these keys and send them on. Having progress stamped in the local registry is useful if we need to troubleshoot on the device directly.
  • Run readiness checks, such as
    • Checking for client OS
    • Checking disk space
  • Check for internet connectivity
  • Determine the approximate bandwidth to the Azure CDN and measure latency. This is done by downloading a 100MB file from the CDN and timing the download and using ‘psping.exe’ to measure latency. From this, we can calculate an approximate download time for the main ESD file.
  • Determine if the device is connected by wire or wireless
  • Determine if the device is connected to the corporate network
  • If the device is on the corporate network, we check latency to all the ConfigMgr distribution points to determine which one will be the best DP to get content from
  • Determine whether OS is business or consumer and which language. This helps us figure out which ESD file to use.
  • Download WindowsUpdateBox.exe and verify the hash
  • Download the feature update ESD file and verify the hash
    • Downloads of FU content is done using BITS transfers as this proved the most reliable method. Code is added to handle BITS transfer errors to add resilience.
  • Assuming all the above is done successfully, the Preparation task will be disabled and the PreDownload task created.

PreDownload Script

The purpose of this script is to run the equivalent of a compatibility assessment. When using the ESD file, this is done with the /PreDownload switch on WindowsUpdateBox.exe. Should the PreDownload fail, the error code will be logged to the registry. Since 2004, we also read the SetupDiag results and stamp these to the registry. We also check the Compat*.xml files to look for any hard blocks and if found, we log the details to the registry.

If the PreDownload failed, we change the schedule of the task to run twice a week. This allows for remediation to be performed on the device before attempting the PreDownload assessment again.

If the PreDownload succeeds, we disable the PreDownload task and create two new ones – a Notification task and an Upgrade task.

We also create a desktop shortcut that the user can use to initiate the upgrade.

Notification Script

The Notification script runs in the user context and displays toast notifications to notify the user that the upgrade is available, what the deadline is and how to upgrade, as already mentioned.

Upgrade Script

When the user clicks the desktop shortcut or the ‘Install now’ button on the toast notification, the upgrade is initiated. Because the upgrade needs to run with administrative privilege, the only thing the desktop shortcut and toast notification button does is to create an entry in the Application event log. The upgrade scheduled task is triggered when this event is created and the task runs in SYSTEM context. The UI is displayed in the user session with the help of the handy ServiceUI.exe from the MDT toolkit.

Upgrade UI

The user interface part of the upgrade is essentially a WPF application coded in PowerShell. The UI displays some basic upgrade information for the user and once they click ‘Begin’ we run the upgrade in 3 stages:

  1. PreDownload. Even though we ran this already, we run again before installing just to make sure nothing has changed since, and it doesn’t take long to run.
  2. Install. This uses the /Install switch on WindowsUpdateBox.exe and runs the main part of the online phase of the upgrade.
  3. Finalize. This uses the /Finalize switch and finalizes the update in preparation for a computer restart.

The progress of each of these phases is tracked in the registry and displayed in the UI using progress bars. If there is an issue, we notify the user and IT can get involved to remediate.

If successful, the user can restart the computer immediately or a later point (though we discourage this!). We don’t stop the user from working while the upgrade is running in the online phase and we allow them to partially hide the upgrade window so the upgrade does not hinder user productivity (similar to how WUfB installs an update in the background.)

After the user restarts the computer, the usual Windows Update screens take over until the update has installed and the user is brought to the login screen again.

Drivers and Stuff

We had considered upgrading drivers and even apps with this process, as we did for the 1903 upgrade, however user experience was important for us and we didn’t want the upgrade to take any longer than necessary, so we decided not to chain anything onto the upgrade process itself but handle other things separately. That being said, because this is a custom solution it is perfectly possible to incorporate additional activities into it if desired.


In the event the that OS was rolled back during the offline phase, a scheduled task will run that will detect this and raise a toast notification to inform the user. We have a script that will gather logs and data from the device and upload it to a storage account in Azure where an administrator can remotely diagnose the issue. I plan to incorporate that as an automatic part of the process in a future version.

Updater Script

The solution creates an Updater scheduled task which runs once per day. The purpose of this task is to keep the solution up to date. If we want to change something in the process, add some code to a file or whatever is necessary, the Updater will take care of this.

It works by downloading a manifest file from the Azure CDN. This file contains all the files used by the solution with their current versions. If we update something, we upload the new files to the Azure storage account, purge them from the CDN and update the manifest file.

The Updater script will download the current manifest, detect that something has changed and download the required files to the device.

Cleanup Script

A Cleanup task is also created. When this task detects that the OS has been upgraded to the required version, it will remove all the scheduled tasks and cached content to leave no footprint on the device other than the log file and the registry keys.

Source Files

You can find a generalised version of the code used in this solution in my Github repo as a reference. As mentioned before though, there are many working parts to the solution including the Azure services and I haven’t documented their configuration here.

Final Comments

The main benefit of this solution for us is that it is completely customised to our needs. Although it is relatively complex to create, it is also relatively easy to maintain as well as adapt the solution for new W10 versions. We do still take advantage of products like ConfigMgr to allow devices to get content from a local DP if they are corporate connected, and ConfigMgr / Update Compliance / Desktop Analytics for helping us determine device compatibility and ConfigMgr or Intune to actually get the deployment script to the device. We also make good use of Azure services for the status messages and the cloud database, as well as PowerBI for reporting. So the solution still utilizes existing Microsoft products while giving us the control and customisations that we need to provide a better upgrade experience for our users.

Windows 10 Feature Update Readiness PowerBI Report (MEMCM version)

Following on from my previous post where I shared a PowerBI report that provides information on Windows 10 feature update blocks using Update Compliance and Desktop Analytics, in this post I will share another report that exposes similar data, but this time built from custom hardware inventory data in MEMCM.

Outside of the Windows setup process, feature update compatibility is assessed on a Windows 10 device by the ‘Microsoft Compatibility Appraiser’ – a scheduled task that runs daily and the results of which form part of the telemetry data that gets uploaded to Microsoft from the device. The specifics of this process are still somewhat shrouded in mystery, but thanks to the dedication of people like Adam Gross we can lift the lid a little bit on understanding compatibility assessment results. I highly recommend reading his blog if your interested in a more in-depth understanding.

Some compatibility data is stored in the registry under HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags, and in particular the subkey TargetVersionUpgradeExperienceIndicators contains useful compatibility information for different W10 releases such as the GatedBlockIds, otherwise known as the Safeguard Hold Ids, some of which are published by Microsoft on their Windows 10 Known Issues pages. Since the release of Windows 10 2004, many devices have been prevented from receiving a W10 feature update by these Safeguard holds, so reporting on them can be useful to identify which devices are affected by which blocks, and which devices are not affected and are candidates for upgrade.

By inventorying these registry keys with MEMCM I built the PowerBI report shown above where you can view data such as:

  • Which Safeguard holds are affecting which devices
  • Rank of model count
  • Rank of OS version count
  • The upgrade experience indicators (UE)
  • UE red block reasons

Note that this data alone doesn’t replace a solution like Desktop Analytics which can help identify devices with potential app or driver compatibility issues, but it’s certainly helpful with the Safeguard holds.

You can also use this data to build collections in MEMCM containing devices that are affected by a Safeguard hold. Because this is based on inventory data, when a Safeguard hold is released by Microsoft those devices will move naturally out those collections.

Understanding the data

Because of the lack of any public documentation around the compatibility appraiser process, we have to take (hopefully!) intelligent guesses as to what the data means.

Under the TargetVersionUpgradeExperienceIndicators registry key for example, you may find subkeys for 19H1, 20H1, 21H1 or even older Windows 10 versions. I haven’t found any keys for *H2 releases though, and I can only assume it’s because the Safeguard holds for a H1 release are the same for the H2 release. From the Windows 10 Known Issues documentation this seems to be the case.

There is also a UNV subkey – I assume that means Universal and contains data that applies across any feature update.

Under the *H1 keys (I suppose I should call it a branch, really) we can try to understand some of the main keys such as:

  • FailedPrereqs – I haven’t seen any devices yet that actually failed the appraiser’s prerequisites, but I assume the details would be logged here if they were.
  • AppraiserVersion, SdbVer, Version, DateVer* – I assume these indicate the version of the compatibility appraiser database used for the assessment
  • DataExp*, DataRel* – these seem to indicate the release and expiry dates for the Appraiser database so my assumption is a new one will be downloaded at or before expiry
  • GatedBlock* – the Id key in particular gives the Safeguard Hold Id/s that are blocking the device from upgrade
  • Perf – this appears to be a general assessment of the performance of the device. A low performing device will likely take longer to upgrade
  • UpgEx* – these seem to be a traffic-light rating for the ‘upgrade experience’. The UpgExU seems to stand for Upgrade Experience Usage – I don’t know what the difference between the two is. Green is good, right, so a green device is going to be a good upgrade experience, yellow or orange not so great, red is a blocker. I don’t know exactly what defines each colour other than that…
  • RedReason – if you’ve got a red device, it’s blocked from upgrade by something – but this isn’t related to Safeguard holds as far as I can tell. It seems to be related to the keys under HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\CompatMarkers\*H1, such as BlockedByBios, BlockedByCpu for example. The only one I’ve seen in practice is the SystemDriveTooFull block.

Configure Custom Hardware Inventory

Alright, so first we need to configure hardware inventory in MEMCM to include the registry keys we want to use. You can use the RegKey2Mof utility, or you can download the files below to update your Configuration.mof file and your Client Settings / hardware inventory classes. I’ll assume you are familiar with that process.




If you choose to use different names for the created classes, you’ll need to update the PowerBI report as it uses those names.

Download the PowerBI Report

Download the PBI template from here:

Windows 10 Feature Updates Readiness

Open opening, you’ll need to add the SQL server and database name for your MEMCM database:

You won’t see any data until devices have started sending hardware inventory that includes the custom classes.

Note that I have included pages for both 20H1 and 21H1, but the latter is just a placeholder for now as no actual compatibility data will be available until that version is released, or close to.

MEMCM collections

You can also build collections like those shown above by adding a query rule and using the names created by the custom classes – in this case TwentyHOne and TwentyOneHOne. Use the value option to find which GatedBlockIds are present in your environment.

Hope it helps!

PowerBI Reports for Windows 10 Feature Update Compliance

This morning I saw an interesting tweet from Sandy Zeng with a Log Analytics workbook she’d created for W10 feature updates based on Update Compliance data. I’d been meaning to create a similar report for that myself in PowerBI for some time, so I took inspiration from her tweet and got to work on something!

Microsoft’s Update Compliance solution can be used to report on software updates and feature updates status across your estate from Windows telemetry data. If you use Desktop Analytics, you can even combine the data for richer reporting.

Log Analytics allows exporting of queries in Power Query M formula language, which can be imported into PowerBI to create some nice reports.

Here’s a screenshot of what I ended up with. You can filter the data to view devices with Safeguard holds, for example, which since Windows 10 2004 has been a show stopper for many wanting to upgrade…

There are pages for 2004 and 20H2, as well as a page listing some of the known Safeguard hold IDs that have been publicly disclosed by Microsoft.

You can download this report for your own use with the links below. Note I have created two reports – the first assumes you have both Update Compliance and Desktop Analytics using the same Log Analytics workspace. If this is not the case for you, download the second report which doesn’t link to DA data and just uses Update Compliance. The only data I’ve included from DA is the make and model since these can be helpful in analysing devices affected by Safeguard holds.

Windows 10 Feature Update Compliance

Windows 10 Feature Update Compliance (no DA)

To create your own report, you’ll need the latest version of PowerBI desktop installed, with preview support for dynamic M query parameters.

Open PowerBI and go to File > Options and settings > Options > Preview features and enable Dynamic M Query Parameters.

Restart PowerBI and open the downloaded PBI template. Upon opening, you’ll be prompted for the Log Analytics workspace ID. You can find this on the Overview pane of your workspace in the Azure portal.

The reports contain data with a timespan of the last 48 hours, but you can change this if you want by editing the queries in the Advanced editor, and changing the value “P2D”.

You also might want to play around with the LastScan filter so you only get devices with a recent scan date, and avoid duplicates.

Hope it’s helpful!

Get a daily admin Audit Report for MEM / Intune

In an environment where you have multiple admin users it’s useful to audit admin activities so everyone can be aware of changes that others have made. I do this for Endpoint Configuration Manager with a daily email report built from admin status messages, so I decided to create something similar for Intune / MEM.

Admin actions are already audited for you in MEM (Tenant Administration > Audit logs) so it’s simply a case of getting that data into an email report. You can do this with Graph (which gives you more data actually) but I decided to use Log Analytics for this instead.

You need a Log Analytics workspace, and you need to configure Diagnostics settings in the MEM portal to send AuditLogs to the workspace.

Then, in order to automate sending a daily report create a service principal in Azure AD with just the permissions necessary to read data from the Log Analytics workspace. You can do this easily from the Azure portal using CloudShell. In the example below, I’m creating a new service principal with the role “Log Analytics Reader” scoped just to the Log Analytics workspace where the AuditLogs are sent to.

$DisplayName = "MEM-Reporting"
$Role = "Log Analytics Reader"
$Scope = "/subscriptions/<subscriptionId>/resourcegroups/<resourcegroupname>/providers/microsoft.operationalinsights/workspaces/<workspacename>"

$sp = New-AzADServicePrincipal -DisplayName $DisplayName -Role $Role -Scope $Scope

With the service principal created, you’ll need to make a note of the ApplicationId:


And the secret:

$SP.Secret | ConvertFrom-SecureString -AsPlainText

Of course, if you prefer you can use certificate authentication instead of using the secret key.

Below is a PowerShell script that uses the Az PowerShell module to connect to the log analytics workspace as the service principal, query the IntuneAuditLogs for entries in the last 24 hours, then send them in an HTML email report. Run it with your favourite automation tool.

You’ll need the app Id and secret from the service principal, your tenant Id, your log analytics workspace Id, and don’t forget to update the email parameters.

Sample email report
# Script to send a daily audit report for admin activities in MEM/Intune
# Requirements:
# – Log Analytics Workspace
# – Intune Audit Logs saved to workspace
# – Service Principal with 'Log Analytics reader' role in workspace
# – Azure Az PowerShell modules
# Azure resource info
$ApplicationId = "abc73938-0000-0000-0000-9b01316a9123" # Service Principal Application Id
$Secret = "489j49r-0000-0000-0000-e2dc6451123" # Service Principal Secret
$TenantID = "abc894e7-00000-0000-0000-320d0334b123" # Tenant ID
$LAWorkspaceID = "abcc1e47-0000-0000-0000-b7ce2b2bb123" # Log Analytics Workspace ID
$Timespan = (New-TimeSpan Hours 24)
# Email params
$EmailParams = @{
To = ''
From = ''
Smtpserver = ''
Port = 25
Subject = "MEM Audit Report | $(Get-Date Format ddMMMyyyy)"
# Html CSS style
$Style = @"
table {
border-collapse: collapse;
font-family: sans-serif
font-size: 12px
td, th {
border: 1px solid #ddd;
padding: 6px;
th {
padding-top: 8px;
padding-bottom: 8px;
text-align: left;
background-color: #3700B3;
color: #03DAC6
# Connect to Azure with Service Principal
$Creds = [PSCredential]::new($ApplicationId,(ConvertTo-SecureString $Secret AsPlaintext Force))
Connect-AzAccount ServicePrincipal Credential $Creds Tenant $TenantID
# Run the Log Analytics Query
$Query = "IntuneAuditLogs | sort by TimeGenerated desc"
$Results = Invoke-AzOperationalInsightsQuery WorkspaceId $LAWorkspaceID Query $Query Timespan $Timespan
$ResultsArray = [System.Linq.Enumerable]::ToArray($Results.Results)
# Converts the results to a datatable
$DataTable = New-Object System.Data.DataTable
$Columns = @("Date","Initiated by (actor)","Application Name","Activity","Operation Status","Target Name","Target ObjectID")
foreach ($Column in $Columns)
foreach ($result in $ResultsArray)
$Properties = $Result.Properties | ConvertFrom-Json
# Send an email
If ($DataTable.Rows.Count -ge 1)
$HTML = $Datatable |
ConvertTo-Html Property "Date","Initiated by (actor)","Application Name","Activity","Operation Status","Target Name","Target ObjectID" Head $Style Body "<h2>MEM Admin Activities in the last 24 hours</h2>" |
Send-MailMessage @EmailParams Body $html BodyAsHtml

Get Program Execution History from a ConfigMgr Client with PowerShell

Have you ever been in the situation where something unexpected happens on a users computer and people start pointing their fingers at the ConfigMgr admin and asking “has anyone deployed something with SCCM?” Well, I decided to write a PowerShell script to retrieve the execution history for ConfigMgr programs on a local or remote client. This gives clear visibility of when and which deployments such as applications/programs/task sequences have run on the client and hopefully acquit you (or prove you guilty!)

Program execution history can be found in the registry but it doesn’t contain the name of the associated package, so I joined that data with software distribution data from WMI to give a better view.

You can run the script against the local machine, or a remote machine if you have PS remoting enabled. You can also run it against multiple machines at the same time and combine the data if desired. I recommend to pipe the results to grid view.

Get-CMClientExecutionHistory -Computername PC001,PC002 | Out-GridView
[string[]]$ComputerName = $env:COMPUTERNAME
$Code = {
# Get Execution History from registry, and package details from WMI
$ExecutionHistoryKey = "HKLM:\SOFTWARE\Microsoft\SMS\Mobile Client\Software Distribution\Execution History"
$ContextKeys = Get-ChildItem $ExecutionHistoryKey | Select ExpandProperty PSChildName
foreach ($ContextKey in $ContextKeys)
If ($ContextKey -eq "System")
$ContextKey = "Machine"
$ContextKey = $ContextKey.Replace('','_')
[array]$SoftwareDistribution += Get-CimInstance Namespace ROOT\ccm\Policy\$ContextKey ClassName CCM_SoftwareDistribution
# Create a datatable to hold the results
$DataTable = New-Object System.Data.DataTable
foreach ($ContextKey in $ContextKeys)
If ($ContextKey -ne "System")
# Get user context if applicable
$SID = New-Object Security.Principal.SecurityIdentifier ArgumentList $ContextKey
$Context = $SID.Translate([System.Security.Principal.NTAccount])
$Context = "Machine"
$SubKeys = Get-ChildItem "$ExecutionHistoryKey\$ContextKey"
Foreach ($SubKey in $SubKeys)
$Items = Get-ChildItem $SubKey.PSPath
Foreach ($Item in $Items)
$PackageInfo = $SoftwareDistribution | Where {$_.PKG_PackageID -eq $SubKey.PSChildName -and $_.PRG_ProgramName -eq $Item.GetValue("_ProgramID")} | Select First 1
If ($PackageInfo)
$PackageName = $PackageInfo.PKG_Name
$DeploymentStatus = "Active"
$PackageName = "-Unknown-"
$DeploymentStatus = "No longer targeted"
$DataTable.DefaultView.Sort = "RunStartTime DESC"
$DataTable = $DataTable.DefaultView.ToTable()
Return $DataTable
foreach ($Computer in $ComputerName)
If ($Computer -eq $env:COMPUTERNAME)
$Result = Invoke-Command ScriptBlock $Code
$Result = Invoke-Command ComputerName $Computer HideComputerName ScriptBlock $Code ErrorAction Continue
$Result | Select ComputerName,PackageName,PackageID,ProgramName,DeploymentStatus,Context,State,RunStartTime,SuccessOrFailureCode,SuccessOrFailureReason

Get Previous and Scheduled Evaluation Times for ConfigMgr Compliance Baselines with PowerShell

I was testing a compliance baseline recently and wanted to verify if the schedule defined in the baseline deployment is actually honored on the client. I set the schedule to run every hour, but it was clear that it did not run every hour and that some randomization was being used.

To review the most recent evaluation times and the next scheduled evaluation time, I had to read the scheduler.log in the CCM\Logs directory, because I could only find a single last evaluation time recorded in WMI.

The following PowerShell script reads which baselines are currently deployed to the local machine, displays a window for you to choose one, then basically reads the Scheduler log to find when the most recent evaluations were and when the next one is scheduled.

Select a baseline
Baseline evaluations
## ##
## Reads the most recent and next scheduled evaluation time ##
## for deployed Compliance Baselines from the Scheduler.log ##
## ##
#requires -RunAsAdministrator
# Get Baselines from WMI
# Excludes co-management policies
$Instances = Get-CimInstance Namespace ROOT\ccm\dcm ClassName SMS_DesiredConfiguration Filter "PolicyType!=1" OperationTimeoutSec 5 ErrorAction Stop | Select DisplayName,IsMachineTarget,Name
Throw "Couldn't get baseline info from WMI: $_"
If ($Instances.Count -eq 0)
Throw "No deployed baselines found!"
# Datatable to hold the baselines for the WPF window
$DataTable = New-Object System.Data.DataTable
foreach ($Instance in ($Instances | Sort DisplayName))
# WPF Window for baseline selection
Add-Type AssemblyName PresentationFramework,PresentationCore,WindowsBase
$Window = New-Object System.Windows.Window
$Window.WindowStartupLocation = [System.Windows.WindowStartupLocation]::CenterScreen
$Window.SizeToContent = [System.Windows.SizeToContent]::WidthAndHeight
$window.ResizeMode = [System.Windows.ResizeMode]::NoResize
$DataGrid = New-Object System.Windows.Controls.DataGrid
$DataGrid.ItemsSource = $DataTable.DefaultView
$DataGrid.CanUserAddRows = $False
$DataGrid.IsReadOnly = $true
$DataGrid.SelectionMode = [System.Windows.Controls.DataGridSelectionMode]::Single
$DataGrid.Height = "NaN"
$DataGrid.MaxHeight = "250"
$DataGrid.Width = "NaN"
$DataGrid.AlternatingRowBackground = "#e6ffcc"
$script:SelectedRow = $This.SelectedValue
If (!$SelectedRow)
Throw "No baseline was selected!"
# If the baseline is user-targetted
If ($SelectedRow.row.IsMachineTarget -eq $false)
# Get Logged-on user SID
$LogonUIRegPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI"
#Could also use this:
#Get-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\SMS\CurrentUser -Name UserSID -ErrorAction Stop
$Property = "LastLoggedOnUserSID"
$LastLoggedOnUserSID = Get-ItemProperty Path $LogonUIRegPath Name $Property | Select ExpandProperty $Property
$LastLoggedOnUserSIDUnderscore = $LastLoggedOnUserSID.Replace('','_')
$Namespace = "ROOT\ccm\Policy\$LastLoggedOnUserSIDUnderscore\ActualConfig"
$Namespace = "ROOT\ccm\Policy\Machine\ActualConfig"
# Get assignment info
$BaselineName = $SelectedRow.Row.DisplayName
$Pattern = [Regex]::Escape($BaselineName)
$CIAssignment = Get-CimInstance Namespace $Namespace ClassName CCM_DCMCIAssignment | where {$_.AssignmentName -match $Pattern}
$AssignmentIDs = $CIAssignment | Select AssignmentID,AssignmentName
Write-host "Baseline: $BaselineName" ForegroundColor Magenta
foreach ($AssignmentID in $AssignmentIDs)
# Read the scheduler log
$Log = "$env:SystemRoot\CCM\Logs\Scheduler.log"
If ($SelectedRow.row.IsMachineTarget -eq $false)
$LogEntries = Select-String Path $Log SimpleMatch "$LastLoggedOnUserSID/$($AssignmentID.AssignmentID)"
$LogEntries = Select-String Path $Log SimpleMatch "Machine/$($AssignmentID.AssignmentID)"
If ($LogEntries)
# Get the previous evaluations date/time
$Evaluations = New-Object System.Collections.ArrayList
$EvaluationEntries = $LogEntries | where {$_ -match "SMSTrigger"}
Foreach ($Entry in $EvaluationEntries)
$Time = $Entry.Line.Split('=')[1]
$Date = $Entry.Line.Split('=')[2]
$a = $Time.Split()[0].trimend().replace('"','')
$b = $Date.Split()[0].trimend().replace('"','').replace('','/')
$Time = (Get-Date $a).ToLongTimeString()
$Date = [DateTime]"$b $Time"
$LocalDate = Get-Date $date Format (Get-Culture).DateTimeFormat.RFC1123Pattern
# Get the next scheduled evaluation date/time
$LastEvaluation = $EvaluationEntries | Select Last 1
$date = $LastEvaluation.Line.Split()[8]
$time = $LastEvaluation.Line.Split()[9]
$ampm = $LastEvaluation.Line.Split()[10]
$NextEvaluation = [DateTime]"$date $time $ampm"
$NextEvaluationLocal = Get-Date $NextEvaluation Format (Get-Culture).DateTimeFormat.RFC1123Pattern
# Return the results
Write-Host "Assignment: $($AssignmentID.AssignmentName)" ForegroundColor Green
Write-host "Last Evaluations:"
foreach ($Evaluation in $Evaluations)
Write-host " $Evaluation" ForegroundColor Yellow
Write-host "Next Scheduled Evaluation:"
Write-Host " $NextEvaluationLocal" ForegroundColor Yellow
Write-Host "No log entries found!" ForegroundColor Red

HTML Report for SCCM Site Component Warnings and Errors

Just a quick one 🙂

If you’re like me you are too lazy busy to regularly check the component status of an SCCM Site Server for any issues, so why not get PowerShell to do it for you?

The code below will email an html-formatted report of any site components that are currently in an error or warning status, together with the last few error or warning status messages for each component. Run it as a scheduled task or with your favorite automation tool to keep your eye on any current issues. Whether you get annoyed because you now created more work for yourself, or get happy because you can stay on top of issues in your SCCM environment, I leave to you!

The report will display the components that are marked as either critical or warning with the current number of messages:

It will then display the last x status messages for each component for a quick view of what the current issue/s are:

Run the script either on the site server or somewhere where the SCCM console is installed, and set the required parameters in the script.

## ##
## This script checks for any SCCM Site Server components currently in an error or warning ##
## state and emails it as an html report, including the latest status messages for each component. ##
## ##
# Site server FQDN
$SiteServer = ""
# Site code
$SiteCode = "ABC"
# Location of the resource dlls in the SCCM admin console path
$script:SMSMSGSLocation =$env:SMS_ADMIN_UI_PATH\00000409
# SCCM SQL Server / instance
$script:dataSource = 'SCCMServer'
# SCCM SQL database
$script:database = 'CM_ABC'
# Number of Status messages to report
$SMCount = 5
# Tally interval – see
$TallyInterval = '0001128000100008'
# Email params
$EmailParams = @{
To = ''
From = ''
Smtpserver = ''
Port = 25
Subject = "SCCM Site Server Component Status Report | $SiteServer | $SiteCode | $(Get-Date Format ddMMMyyyy)"
# Html CSS style
$Style = @"
table {
border-collapse: collapse;
td, th {
border: 1px solid #ddd;
padding: 8px;
th {
padding-top: 12px;
padding-bottom: 12px;
text-align: left;
background-color: #4286f4;
color: white;
h2 {
color: red;
# Function to get data from SQL server
function Get-SQLData {
$connectionString = "Server=$dataSource;Database=$database;Integrated Security=SSPI;"
$connection = New-Object TypeName System.Data.SqlClient.SqlConnection
$connection.ConnectionString = $connectionString
$command = $connection.CreateCommand()
$command.CommandText = $Query
$reader = $command.ExecuteReader()
$table = New-Object TypeName 'System.Data.DataTable'
# Close the connection
return $Table
# Function to get the status message description
function Get-StatusMessage {
param (
# Set the resources dll
Switch ($DLL)
"srvmsgs.dll" { $stringPathToDLL = "$SMSMSGSLocation\srvmsgs.dll" }
"provmsgs.dll" { $stringPathToDLL = "$SMSMSGSLocation\provmsgs.dll" }
"climsgs.dll" { $stringPathToDLL = "$SMSMSGSLocation\climsgs.dll" }
# Load Status Message Lookup DLL into memory and get pointer to memory
$ptrFoo = $Win32LoadLibrary::LoadLibrary($stringPathToDLL.ToString())
$ptrModule = $Win32GetModuleHandle::GetModuleHandle($stringPathToDLL.ToString())
# Set severity code
Switch ($Severity)
"Informational" { $code = 1073741824 }
"Warning" { $code = 2147483648 }
"Error" { $code = 3221225472 }
# Format the message
$result = $Win32FormatMessage::FormatMessage($flags, $ptrModule, $Code -bor $MessageID, 0, $stringOutput, $sizeOfBuffer, $stringArrayInput)
if ($result -gt 0)
# Add insert strings to message
$objMessage = New-Object System.Object
$objMessage | Add-Member type NoteProperty name MessageString value $stringOutput.ToString().Replace("%11","").Replace("%12","").Replace("%3%4%5%6%7%8%9%10","").Replace("%1",$InsString1).Replace("%2",$InsString2).Replace("%3",$InsString3).Replace("%4",$InsString4).Replace("%5",$InsString5).Replace("%6",$InsString6).Replace("%7",$InsString7).Replace("%8",$InsString8).Replace("%9",$InsString9).Replace("%10",$InsString10)
Return $objMessage
# SQL query for component status
$Query = "
when Status = 0 then 'OK'
when Status = 1 then 'Warning'
when Status = 2 then 'Critical'
End as 'Status',
when State = 0 then 'Stopped'
when State = 1 then 'Started'
when State = 2 then 'Paused'
when State = 3 then 'Installing'
when State = 4 then 'Re-installing'
when State = 5 then 'De-installing'
End as 'State',
When AvailabilityState = 0 then 'Online'
When AvailabilityState = 3 then 'Offline'
When AvailabilityState = 4 then 'Unknown'
End as 'AvailabilityState',
from vSMS_ComponentSummarizer
where TallyInterval = N'$TallyInterval'
and MachineName = '$SiteServer'
and SiteCode = '$SiteCode '
and Status in (1,2)
Order by Status,ComponentName
$Results = Get-SQLData Query $Query
# Convert results to HTML
$HTML = $Results |
ConvertTo-Html Property "ComponentName","ComponentType","Status","State","AvailabilityState","Infos","Warnings","Errors" Head $Style Body "<h2>Components in a Warning or Error State</h2>" CssUri "" |
$HTML = $HTML + "<h2></h2><h2>Last $SMCount Error or Warning Status Messages for…</h2>"
If ($Results)
# Start PInvoke Code
$sigFormatMessage = @'
public static extern uint FormatMessage(uint flags, IntPtr source, uint messageId, uint langId, StringBuilder buffer, uint size, string[] arguments);
$sigGetModuleHandle = @'
public static extern IntPtr GetModuleHandle(string lpModuleName);
$sigLoadLibrary = @'
public static extern IntPtr LoadLibrary(string lpFileName);
$Win32FormatMessage = Add-Type MemberDefinition $sigFormatMessage name "Win32FormatMessage" namespace Win32Functions PassThru Using System.Text
$Win32GetModuleHandle = Add-Type MemberDefinition $sigGetModuleHandle name "Win32GetModuleHandle" namespace Win32Functions PassThru Using System.Text
$Win32LoadLibrary = Add-Type MemberDefinition $sigLoadLibrary name "Win32LoadLibrary" namespace Win32Functions PassThru Using System.Text
#End PInvoke Code
$sizeOfBuffer = [int]16384
$stringArrayInput = {"%1","%2","%3","%4","%5", "%6", "%7", "%8", "%9"}
$flags = 0x00000800 -bor 0x00000200
$stringOutput = New-Object System.Text.StringBuilder $sizeOfBuffer
# Process each resulting component
Foreach ($Result in $Results)
# Query SQL for status messages
$Component = $Result.ComponentName
$SMQuery = "
top $SMCount
CASE smsgs.Severity
WHEN -1073741824 THEN 'Error'
WHEN 1073741824 THEN 'Informational'
WHEN -2147483648 THEN 'Warning'
ELSE 'Unknown'
END As 'SeverityName',
case smsgs.MessageType
WHEN 256 THEN 'Milestone'
WHEN 512 THEN 'Detail'
WHEN 768 THEN 'Audit'
WHEN 1024 THEN 'NT Event'
ELSE 'Unknown'
END AS 'Type',
from v_StatusMessage smsgs
join v_StatMsgWithInsStrings smwis on smsgs.RecordID = smwis.RecordID
join v_StatMsgModuleNames modNames on smsgs.ModuleName = modNames.ModuleName
where smsgs.MachineName = '$SiteServer'
and smsgs.Component = '$Component'
and smsgs.Severity in ('-1073741824','-2147483648')
Order by smsgs.Time DESC
$StatusMsgs = Get-SQLData Query $SMQuery
# Put desired fields into an object for each result
$StatusMessages = @()
foreach ($Row in $StatusMsgs)
$Params = @{
MessageID = $Row.MessageID
DLL = $Row.MsgDLLName
Severity = $Row.SeverityName
InsString1 = $Row.InsString1
InsString2 = $Row.InsString2
InsString3 = $Row.InsString3
InsString4 = $Row.InsString4
InsString5 = $Row.InsString5
InsString6 = $Row.InsString6
InsString7 = $Row.InsString7
InsString8 = $Row.InsString8
InsString9 = $Row.InsString9
InsString10 = $Row.InsString10
$Message = Get-StatusMessage @params
$StatusMessage = New-Object psobject
Add-Member InputObject $StatusMessage Name Severity MemberType NoteProperty Value $Row.SeverityName
Add-Member InputObject $StatusMessage Name Type MemberType NoteProperty Value $Row.Type
Add-Member InputObject $StatusMessage Name SiteCode MemberType NoteProperty Value $Row.SiteCode
Add-Member InputObject $StatusMessage Name "Date / Time" MemberType NoteProperty Value $Row.Time
Add-Member InputObject $StatusMessage Name System MemberType NoteProperty Value $Row.MachineName
Add-Member InputObject $StatusMessage Name Component MemberType NoteProperty Value $Row.Component
Add-Member InputObject $StatusMessage Name Module MemberType NoteProperty Value $Row.ModuleName
Add-Member InputObject $StatusMessage Name MessageID MemberType NoteProperty Value $Row.MessageID
Add-Member InputObject $StatusMessage Name Description MemberType NoteProperty Value $Message.MessageString
$StatusMessages += $StatusMessage
# Add to the HTML code
$HTML = $HTML + (
$StatusMessages |
ConvertTo-Html Property "Severity","Date / Time","MessageID","Description" Head $Style Body "<h2>$Component</h2>" CssUri "" |
# Fire the email
Send-MailMessage @EmailParams Body $Html BodyAsHtml

Create Collections for SCCM Client Installation Failures by Error Code

Ok, so in a perfect SCCM world you would never have any SCCM client installation failures and this post would be totally unnecessary. But in the real world, you are very likely to have a number of systems that fail to install the SCCM client and the reasons can be many.

To identify such systems, it can be helpful to create collections for some of the common client installation failure codes so you can easily see and report on which type of installation failures you are experiencing and the number of systems affected.

To identify the installation failure error codes you have in your environment for Windows systems, run the following SQL query against the SCCM database:

	Count(cdr.Name) as 'Count',
	cdr.CP_LastInstallationError as 'Last Installation Error Code'
from v_CombinedDeviceResources cdr
	cdr.CP_LastInstallationError is not null
	and cdr.IsClient = 0
	and cdr.DeviceOS like '%Windows%'
group by cdr.CP_LastInstallationError
order by 'Count' desc
Client installation error counts

Next simply create a collection for each error code using the following WQL query, changing the LastInstallationError value to the relevant error code:

from SMS_R_System as SYS 
Inner Join SMS_CM_RES_COLL_SMS00001 as COL on SYS.ResourceID = COL.ResourceID  
Where COL.LastInstallationError = 53 
And (SYS.Client = 0  Or SYS.Client is null)

Error codes are all fine and dandy, but unless you have an error code database in your head you’ll want to translate those codes to friendly descriptions. To do that, I use a PowerShell function I created that pulls the description from the SrsResources.dll which you can find in any SCCM console installation. There’s more than one way to translate error codes though – see my blog post here. Better yet, create yourself an error code SQL database which you can join to in your SQL queries and is super useful for reporting purposes – see my post here.

Anyway, once you’ve translated the error codes, you can name your collections with them for easy reference:

Client installation failure collections

Now comes the hard part – figuring out how to fix those errors and working through all the affected systems 😬

New Tool: Delivery Optimization Monitor

Delivery Optimization Monitor is a tool for viewing Delivery Optimization data on the local or a remote PC.

It is based on the built-in Delivery Optimization UI in Windows 10 but allows you to view data graphically from remote computers as well.

The tool uses the Delivery Optimization PowerShell cmdlets built in to Windows 10 to retrieve and display DO data, including stats and charts for the current month, performance snapshot data and data on any current DO jobs.


  • A supported version of Windows 10 (1703 onward)
  • PowerShell 5 minimum
  • .Net Framework 4.6.2 minimum
  • PS Remoting enabled to view data from remote computers.

This WPF tool is coded in Xaml and PowerShell and uses the MahApps.Metro and LiveCharts open source libraries.


Download the tool from the Technet Gallery.


To use the tool, extract the ZIP file, right-click the Delivery Optimization Monitor.ps1 and run with PowerShell.

To run against the local machine, you must run the tool elevated. To do so, create a shortcut to the ps1 file. Edit the properties of the shortcut and change the target to read:

PowerShell.exe -ExecutionPolicy Bypass -File “<pathtoPS1file>”

Right-click the shortcut and run as administrator, or edit the shortcut properties (under Advanced) to run as administrator.

For completeness, you can also change the icon of the shortcut to the icon file included in the bin directory.

Delivery Optimization Statistics

There are 3 tabs – the first displays DO data for the current month together with charts for download and upload statistics.

The second tab displays PerfSnap data and the third displays any current DO jobs.

Shout Out

Shout out to Kevin Rahetilahy over at for blogging about LiveCharts in PowerShell.

Source Code

Source code can be found on GitHub.

Create Disk Usage Reports with PowerShell and WizTree

Recently I discovered a neat little utility called WizTree, which can be used to report on space used by files and folders on a drive. There are many utilities out there that can do that, but this one supports running on the command line which makes it very useful for scripting scenarios. It also works extremely quickly because it uses the Master File Table on disk instead of the slower Windows / .Net methods.

I wanted to create a disk usage report for systems that have less than 20GB of free space – the recommended minimum for doing a Windows 10 in-place upgrade – so that I can easily review it and identify files / folders that could potentially be deleted to free space on the disk. I wanted to script it so that it can be run in the background and deployed via ConfigMgr, and the resulting reports copied to a server share for review.

The following script does just that.

First, it runs WizTree on the command line and generates two CSV reports, one each for all files and folders on the drive. Next, since the generated CSV files contain sizes in bytes, the script imports the CSVs, converts the size data to include KB, MB and GB, then outputs to 2 new CSV files.

The script then generates 2 custom HTML reports that contain a list of the largest 100 files and folders, sorted by size.

Next it generates an HTML summary report that shows visually how much space is used on the disk and tells you how much space you need to free up to drop under the minimum 20GB-free limit.

Finally, it copies those reports to a server share, which will look like this:


The Disk Usage Summary report will look something like this:


And here’s a snippet from the large directories and files reports:



There are also CSV reports which contain the entire list of files and directories on the drive:


To use the script, simply download the WizTree Portable app, extract the WizTree64.exe and place it in the same location as the script (assuming 64-bit OS).  Set the run location in the script (ie $PSScriptRoot if calling the script, or the directory location if running in the ISE), the temporary location where it can create files, and the server share where you want to copy the reports to. Then just run the script in admin context.

# Script to export html and csv reports of file and directory content on the system drive
# Use to identify large files/directories for disk space cleanup
# Uses WizTree portable to quickly retrieve file and directory sizes from the Master File Table on disk
# Download and extract the WizTree64.exe and place in the same directory as this script
# Set the running location
$RunLocation = $PSScriptRoot
#$RunLocation = "C:\temp"
$TempLocation = "C:\temp"
# Set Target share to copy the reports to
$TargetRoot = "\\server-01\sharename\DirectorySizeInfo"
# Free disk space thresholds (percentages) for summary report
$script:Thresholds = @{}
$Thresholds.Warning = 80
$Thresholds.Critical = 90
# Custom function to exit with a specific code
function ExitWithCode
# Function to set the progress bar colour based on the the threshold value in the summary report
function Set-PercentageColour {
If ($Value -lt $Thresholds.Warning)
$Hex = "#00ff00" # Green
If ($Value -ge $Thresholds.Warning -and $Value -lt $Thresholds.Critical)
$Hex = "#ff9900" # Amber
If ($Value -ge $Thresholds.Critical)
$Hex = "#FF0000" # Red
Return $Hex
# Define Html CSS style
$Style = @"
table {
border-collapse: collapse;
td, th {
border: 1px solid #ddd;
padding: 8px;
th {
padding-top: 12px;
padding-bottom: 12px;
text-align: left;
background-color: #4286f4;
color: white;
# Set the filenames of WizTree csv's
$FilesCSV = "Files_$(Get-Date Format 'yyyyMMdd_hhmmss').csv"
$FoldersCSV = "Folders_$(Get-Date Format 'yyyyMMdd_hhmmss').csv"
# Set the filenames of customised csv's
$ExportedFilesCSV = "Exported_Files_$(Get-Date Format 'yyyyMMdd_hhmmss').csv"
$ExportedFoldersCSV = "Exported_Folders_$(Get-Date Format 'yyyyMMdd_hhmmss').csv"
# Set the filenames of html reports
$ExportedFilesHTML = "Largest_Files_$(Get-Date Format 'yyyyMMdd_hhmmss').html"
$ExportedFoldersHTML = "Largest_Folders_$(Get-Date Format 'yyyyMMdd_hhmmss').html"
$SummaryHTMLReport = "Disk_Usage_Summary_$(Get-Date Format 'yyyyMMdd_hhmmss').html"
# Run the WizTree portable app
Start-Process FilePath "$RunLocation\WizTree64.exe" ArgumentList """$Env:SystemDrive"" /export=""$TempLocation\$FilesCSV"" /admin 1 /sortby=2 /exportfolders=0" Verb runas Wait
Start-Process FilePath "$RunLocation\WizTree64.exe" ArgumentList """$Env:SystemDrive"" /export=""$TempLocation\$FoldersCSV"" /admin 1 /sortby=2 /exportfiles=0" Verb runas Wait
#region Files
# Remove the first 2 rows from the CSVs to leave just the relevant data
$CSVContent = Get-Content Path $TempLocation\$FilesCSV ReadCount 0
$CSVContent = $CSVContent | Select Skip 1
$CSVContent = $CSVContent | Select Skip 1
# Create a table to store the results
$Table = [System.Data.DataTable]::new("Directory Structure")
[void]$Table.Columns.Add([System.Data.DataColumn]::new("Size (Bytes)",[System.Int64]))
[void]$Table.Columns.Add([System.Data.DataColumn]::new("Size (KB)",[System.Decimal]))
[void]$Table.Columns.Add([System.Data.DataColumn]::new("Size (MB)",[System.Decimal]))
[void]$Table.Columns.Add([System.Data.DataColumn]::new("Size (GB)",[System.Decimal]))
# Populate the table from the CSV data
Foreach ($csvrow in $CSVContent)
$Content = $csvrow.split(',')
[void]$Table.rows.Add(($Content[0].Replace('"','')),$Content[2],([math]::Round(($Content[2] / 1KB),2)),([math]::Round(($Content[2] / 1MB),2)),([math]::Round(($Content[2] / 1GB),2)))
# Export the table to a new CSV
$Table | Sort 'Size (Bytes)' Descending | Export-CSV Path $TempLocation\$ExportedFilesCSV NoTypeInformation UseCulture
# Export the largest 100 results into html format
$Table |
Sort 'Size (Bytes)' Descending |
Select First 100 |
ConvertTo-Html Property 'Name','Size (Bytes)','Size (KB)','Size (MB)','Size (GB)' Head $style Body "<h2>100 largest files on $env:COMPUTERNAME</h2>" CssUri "" |
Out-String | Out-File $TempLocation\$ExportedFilesHTML
#region Folders
# Remove the first 2 rows from the CSVs to leave just the relevant data
$CSVContent = Get-Content Path $TempLocation\$FoldersCSV ReadCount 0
$CSVContent = $CSVContent | Select Skip 1
$CSVContent = $CSVContent | Select Skip 1
# Create a table to store the results
$Table = [System.Data.DataTable]::new("Directory Structure")
[void]$Table.Columns.Add([System.Data.DataColumn]::new("Size (Bytes)",[System.Int64]))
[void]$Table.Columns.Add([System.Data.DataColumn]::new("Size (KB)",[System.Decimal]))
[void]$Table.Columns.Add([System.Data.DataColumn]::new("Size (MB)",[System.Decimal]))
[void]$Table.Columns.Add([System.Data.DataColumn]::new("Size (GB)",[System.Decimal]))
# Populate the table from the CSV data
Foreach ($csvrow in $CSVContent)
$Content = $csvrow.split(',')
[void]$Table.rows.Add($($Content[0].Replace('"','')),$Content[2],([math]::Round(($Content[2] / 1KB),2)),([math]::Round(($Content[2] / 1MB),2)),([math]::Round(($Content[2] / 1GB),2)),$Content[5],$Content[6])
# Export the table to a new CSV
$Table | Sort 'Size (Bytes)' Descending | Export-CSV Path $TempLocation\$ExportedFoldersCSV NoTypeInformation UseCulture
# Export the largest 100 results into html format
$Table |
Sort 'Size (Bytes)' Descending |
Select First 100 |
ConvertTo-Html Property 'Name','Size (Bytes)','Size (KB)','Size (MB)','Size (GB)','Files','Folders' Head $style Body "<h2>100 largest directories on $env:COMPUTERNAME</h2>" CssUri "" |
Out-String | Out-File $TempLocation\$ExportedFoldersHTML
#region Create HTML disk usage summary report
# Get system drive data
$WMIDiskInfo = Get-CimInstance ClassName Win32_Volume Property Capacity,FreeSpace,DriveLetter | Where {$_.DriveLetter -eq $env:SystemDrive} | Select Capacity,FreeSpace,DriveLetter
$DiskInfo = [pscustomobject]@{
DriveLetter = $WMIDiskInfo.DriveLetter
'Capacity (GB)' = [math]::Round(($WMIDiskInfo.Capacity / 1GB),2)
'FreeSpace (GB)' = [math]::Round(($WMIDiskInfo.FreeSpace / 1GB),2)
'UsedSpace (GB)' = [math]::Round((($WMIDiskInfo.Capacity / 1GB) ($WMIDiskInfo.FreeSpace / 1GB)),2)
'Percent Free' = [math]::Round(($WMIDiskInfo.FreeSpace * 100 / $WMIDiskInfo.Capacity),2)
'Percent Used' = [math]::Round((($WMIDiskInfo.Capacity $WMIDiskInfo.FreeSpace) * 100 / $WMIDiskInfo.Capacity),2)
# Create html header
$html = @"
<!DOCTYPE html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href=""&gt;
# Set html
$html = $html + @"
<h2>Disk Space Usage for Drive $($DiskInfo.DriveLetter) on $env:COMPUTERNAME</h2>
<table cellpadding="0" cellspacing="0" width="700">
<td style="background-color:$(Set-PercentageColour Value $($DiskInfo.'Percent Used'));padding:10px;color:#ffffff;" width="$($DiskInfo.'Percent Used')%">
$($DiskInfo.'UsedSpace (GB)') GB ($($DiskInfo.'Percent Used') %)
<td style="background-color:#eeeeee;padding-top:10px;padding-bottom:10px;color:#333333;" width="$($DiskInfo.'Percent Used')%">
<table cellpadding="0" cellspacing="0" width="700">
<td style="padding:5px;" width="80%">
Capacity: $($DiskInfo.'Capacity (GB)') GB
<td style="padding:5px;" width="80%">
FreeSpace: $($DiskInfo.'FreeSpace (GB)') GB
<td style="padding:5px;" width="80%">
Percent Free: $($DiskInfo.'Percent Free') %
If ($DiskInfo.'FreeSpace (GB)' -lt 20)
$html = $html + @"
<table cellpadding="0" cellspacing="0" width="700">
<td style="padding:5px;color:red;font-weight:bold" width="80%">
You need to free $(20 $DiskInfo.'FreeSpace (GB)') GB on this disk to pass the W10 readiness check!
# Close html document
$html = $html + @"
# Export to file
$html |
Out-string |
Out-File $TempLocation\$SummaryHTMLReport
#region Copy files to share
# Create a subfolder with computername if doesn't exist
If (!(Test-Path $TargetRoot\$env:COMPUTERNAME))
$null = New-Item Path $TargetRoot Name $env:COMPUTERNAME ItemType Directory
# Create a subdirectory with current date-time
$DateString = ((Get-Date).ToUniversalTime() | get-date Format "yyyy-MM-dd_HH-mm-ss").ToString()
If (!(Test-Path $TargetRoot\$env:COMPUTERNAME\$DateString))
$null = New-Item Path $TargetRoot\$env:COMPUTERNAME Name $DateString ItemType Directory
# Set final target location
$TargetLocation = "$TargetRoot\$env:COMPUTERNAME\$DateString"
# Copy files
$Files = @(
Robocopy $TempLocation $TargetLocation $Files /R:10 /W:5 /NP
Catch {}
# Cleanup temp files
$Files = @(
Foreach ($file in $files)
Remove-Item Path $TempLocation\$file Force
# Force a code 0 on exit, in case of some non-terminating error.
ExitWithCode 0