L’expérience utilisateur est un paramètre essentiel dans le monde informatique actuel, en particulier pour les administrateurs informatiques et les fournisseurs de services gérés (MSP) qui gèrent les environnements des utilisateurs finaux. Avec l’importance croissante accordée au contrôle de l’expérience numérique (DEM – Digital Experience Monitoring) et à l’expérience des appareils (DEX – Device Experience), la capacité de collecter, de stocker et d’analyser efficacement le retour d’information des utilisateurs est devenue une priorité. L’automatisation de ce processus permet d’obtenir des informations cohérentes et de réduire les tâches manuelles. Cet article explique comment collecter les avis des utilisateurs avec PowerShell et les stocker dans les champs personnalisés de NinjaOne, transformant ainsi les avis bruts en informations structurées et exploitables.
Contexte
Alors que les entreprises s’orientent vers une assistance informatique proactive, l’enquête DEX est devenue un outil précieux pour mesurer la façon dont les utilisateurs perçoivent les performances, la réactivité et la facilité d’utilisation des appareils. Le script dont il est question ici est le deuxième d’un flux de travail en trois parties. Le script 1 invite les utilisateurs à donner leur avis et enregistre leurs réponses localement. Le script 2, qui fait l’objet de cet article, lit ces fichiers journaux, traite les derniers avis et les enregistre dans les champs personnalisés de NinjaOne : pour les avis récents et pour l’historique des avis (facultatif). Pour les professionnels de l’informatique qui utilisent NinjaOne, cette intégration transparente garantit que les données sur les sentiments des utilisateurs sont facilement accessibles dans la plateforme RMM pour l’établissement de rapports ou l’application de mesures correctives.
Le script
#Requires -Version 5.1
<#
.SYNOPSIS
Script 2 of 3 in the DEX Survey experience. Saves the latest feedback from DEX Survey - Script 1 to the multiline custom field 'Latest User Feedback' and optionally stores feedback history in the WYSIWYG field 'User Feedback History'.
.DESCRIPTION
Script 2 of 3 in the DEX Survey experience. Saves the latest feedback from DEX Survey - Script 1 to the multiline custom field 'Latest User Feedback' and optionally stores feedback history in the WYSIWYG field 'User Feedback History'.
By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use.
Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms.
Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party.
Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider.
Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations.
Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks.
Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script.
EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA).
.EXAMPLE
-MaxCommentsToKeep "30" -KeepFeedbackHistory
Gathering all user profiles.
Gathering each user's feedback log location.
Checking 'C:\Users\tuser1\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' for user feedback.
New feedback was found.
Checking 'C:\Users\tuser2\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' for user feedback.
New feedback was found.
Checking 'C:\Users\kpradlander\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' for user feedback.
New feedback was found.
Compiling the most recent feedback.
All the most recent feedback has already been collected.
Attempting to save the feedback to the custom field 'userFeedbackHistory'.
Successfully saved the feedback history to the custom field.
Updating the feedback log(s) to indicate that the feedback has been collected.
Updated the log file at 'C:\Users\tuser2\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log'.
Updated the log file at 'C:\Users\kpradlander\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log'.
Updated the log file at 'C:\Users\tuser1\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log'.
Cleaning up the log files so only the previous '30' are kept.
The log file at 'C:\Users\tuser1\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' already has less than or equal to '30' comments.
The log file at 'C:\Users\tuser2\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' already has less than or equal to '30' comments.
The log file at 'C:\Users\kpradlander\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log' already has less than or equal to '30' comments.
.PARAMETER KeepFeedbackHistory
This stores the user feedback history in a custom field titled 'User Feedback History'.
.PARAMETER MaxCommentsToKeep
This controls the number of feedback comments to retain in the log file(s).
.PARAMETER MultilineCustomFieldName
The name of a multiline custom field to store the latest user feedback.
.PARAMETER WYSIWYGCustomFieldName
The name of a WYSIWYG custom field to store the total user feedback history.
.NOTES
Minimum OS Architecture Supported: Windows 10, Windows Server 2016
Release Notes: Initial Release
#>
[CmdletBinding()]
param (
[Parameter()]
[Switch]$KeepFeedbackHistory = [System.Convert]::ToBoolean($env:KeepFeedbackHistory),
[Parameter()]
[String]$MaxCommentsToKeep = "30",
[Parameter()]
[String]$MultilineCustomFieldName = "latestUserFeedback",
[Parameter()]
[String]$WYSIWYGCustomFieldName = "userFeedbackHistory"
)
begin {
# If the script form variables are used, replace the command-line parameters with their values.
if ($env:maxCommentsToKeep) { $MaxCommentsToKeep = $env:maxCommentsToKeep }
# Check if the operating system build version is less than 10240 (Windows 10 or Windows Server 2016 minimum requirement)
if ([System.Environment]::OSVersion.Version.Build -lt 10240) {
Write-Host -Object "`n[Warning] OS build '$([System.Environment]::OSVersion.Version.Build)' detected."
Write-Host -Object "[Warning] The minimum OS version supported by this script is Windows 10 (10240) or Windows Server 2016 (14393).`n"
}
# Validate that the maximum number of comments to keep is not null or empty
if ([String]::IsNullOrWhiteSpace($MaxCommentsToKeep)) {
Write-Host -Object "[Error] You must specify a maximum number of comments to keep."
exit 1
}
# Ensure the value for 'MaxCommentsToKeep' contains only numeric characters
if ($MaxCommentsToKeep -match "[^0-9]") {
Write-Host -Object "[Error] The maximum number of comments you specified '$MaxCommentsToKeep' is invalid. It contains invalid characters."
Write-Host -Object "[Error] Please specify a positive whole number that is greater than 0 and less than $([int]::MaxValue)."
exit 1
}
# Attempt to convert the 'MaxCommentsToKeep' value to a long integer
try {
[long]$CommentsToKeep = $MaxCommentsToKeep
} catch {
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Unable to convert '$MaxCommentsToKeep' into an integer."
exit 1
}
# Validate that the converted value is within the acceptable range
if ($CommentsToKeep -le 0 -or $CommentsToKeep -ge [int]::MaxValue) {
Write-Host -Object "[Error] The maximum number of comments you specified '$CommentsToKeep' is invalid."
Write-Host -Object "[Error] Please specify a positive whole number that is greater than 0 and less than $([int]::MaxValue)."
exit 1
}
function ConvertFrom-UrlSafeBase64 {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[String]$UrlSafeBase64
)
# Normalize base64 (replace URL-safe chars)
$Base64 = $UrlSafeBase64.Replace('-', '+').Replace('_', '/')
# Add padding if needed
switch ($Base64.Length % 4) {
2 { $Base64 = "$Base64==" }
3 { $Base64 = "$Base64=" }
1 { throw "Invalid Base64 string" }
}
# Convert from base64 to text
$InputInBytes = [System.Convert]::FromBase64String($Base64)
[System.Text.Encoding]::UTF8.GetString($InputInBytes)
}
function Get-UserHive {
[CmdletBinding()]
param (
[Parameter()]
[ValidateSet('AzureAD', 'DomainAndLocal', 'All')]
[String]$Type = "All",
[Parameter()]
[String[]]$ExcludedUsers,
[Parameter()]
[switch]$IncludeDefault
)
# Define the SID patterns to match based on the selected user type
$Patterns = switch ($Type) {
"AzureAD" { "S-1-12-1-(\d+-?){4}$" }
"DomainAndLocal" { "S-1-5-21-(\d+-?){4}$" }
"All" { "S-1-12-1-(\d+-?){4}$" ; "S-1-5-21-(\d+-?){4}$" }
}
# Retrieve user profile information based on the defined patterns
$UserProfiles = Foreach ($Pattern in $Patterns) {
Get-ItemProperty "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\*" |
Where-Object { $_.PSChildName -match $Pattern } |
Select-Object @{Name = "SID"; Expression = { $_.PSChildName } },
@{Name = "Username"; Expression = { "$($_.ProfileImagePath | Split-Path -Leaf)" } },
@{Name = "Domain"; Expression = { if ($_.PSChildName -match "S-1-12-1-(\d+-?){4}$") { "AzureAD" }else { $Null } } },
@{Name = "UserHive"; Expression = { "$($_.ProfileImagePath)\NTuser.dat" } },
@{Name = "Path"; Expression = { $_.ProfileImagePath } }
}
# If the IncludeDefault switch is set, add the Default profile to the results
switch ($IncludeDefault) {
$True {
$DefaultProfile = "" | Select-Object Username, SID, UserHive, Path
$DefaultProfile.Username = "Default"
$DefaultProfile.Domain = $env:COMPUTERNAME
$DefaultProfile.SID = "DefaultProfile"
$DefaultProfile.Userhive = "$env:SystemDrive\Users\Default\NTUSER.DAT"
$DefaultProfile.Path = "C:\Users\Default"
# Exclude users specified in the ExcludedUsers list
$DefaultProfile | Where-Object { $ExcludedUsers -notcontains $_.Username }
}
}
if ($PSVersionTable.PSVersion.Major -lt 3) {
$AllAccounts = Get-WmiObject -Class "win32_UserAccount"
} else {
$AllAccounts = Get-CimInstance -ClassName "win32_UserAccount"
}
$CompleteUserProfiles = $UserProfiles | ForEach-Object {
$SID = $_.SID
$Win32Object = $AllAccounts | Where-Object { $_.SID -like $SID }
if ($Win32Object) {
$Win32Object | Add-Member -NotePropertyName UserHive -NotePropertyValue $_.UserHive
$Win32Object | Add-Member -NotePropertyName Path -NotePropertyValue $_.Path
$Win32Object
} else {
[PSCustomObject]@{
Name = $_.Username
Domain = $_.Domain
SID = $_.SID
UserHive = $_.UserHive
Path = $_.Path
}
}
}
# Return the list of user profiles, excluding any specified in the ExcludedUsers list
$CompleteUserProfiles | Where-Object { $ExcludedUsers -notcontains $_.Name }
}
function Test-IsElevated {
[CmdletBinding()]
param ()
# Get the current Windows identity of the user running the script
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
# Create a WindowsPrincipal object based on the current identity
$p = New-Object System.Security.Principal.WindowsPrincipal($id)
# Check if the current user is in the Administrator role
# The function returns $True if the user has administrative privileges, $False otherwise
# 544 is the value for the Built In Administrators role
# Reference: https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsbuiltinrole
$p.IsInRole([System.Security.Principal.WindowsBuiltInRole]'544')
}
if (!$ExitCode) {
$ExitCode = 0
}
}
process {
# Attempt to determine if the current session is running with Administrator privileges.
try {
$IsElevated = Test-IsElevated -ErrorAction Stop
} catch {
# Log an error if unable to determine elevation status
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Unable to determine if the account '$env:Username' is running with Administrator privileges."
exit 1
}
# Exit if the script is not running with Administrator privileges
if (!$IsElevated) {
Write-Host -Object "[Error] Access Denied: The user '$env:Username' does not have administrator privileges, or the script is not running with elevated permissions."
exit 1
}
try {
# Retrieve all user profiles on the system
Write-Host -Object "Gathering all user profiles."
$UserProfiles = Get-UserHive -Type "All" -ErrorAction Stop
$ProfileWasLoaded = New-Object System.Collections.Generic.List[object]
} catch {
# Log an error if unable to gather user profiles
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to gather all the user profiles."
exit 1
}
# Loop through each user profile to load their registry hive if not already loaded
ForEach ($UserProfile in $UserProfiles) {
If (!(Test-Path -Path Registry::HKEY_USERS\$($UserProfile.SID) -ErrorAction SilentlyContinue)) {
Start-Process -FilePath "cmd.exe" -ArgumentList "/C reg.exe LOAD HKU\$($UserProfile.SID) `"$($UserProfile.UserHive)`"" -Wait -WindowStyle Hidden
$ProfileWasLoaded.Add($UserProfile)
}
}
# Locate feedback log files for each user profile
Write-Host -Object "Gathering each user's feedback log location."
$FeedbackLogLocations = New-Object System.Collections.Generic.List[object]
$UserProfiles | ForEach-Object {
# Check for feedback log file in various locations
$AppDataLocation = Get-ItemProperty -Path "Registry::HKEY_USERS\$($_.SID)\Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty "Local AppData" -ErrorAction SilentlyContinue
if ($AppDataLocation -and (Test-Path -Path "$AppDataLocation\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log" -Type Leaf -ErrorAction SilentlyContinue)) {
$FeedbackLogLocations.Add(
[PSCustomObject]@{
Name = $_.Name
Domain = $_.Domain
LogFileLocation = "$AppDataLocation\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log"
}
)
return
}
if (Test-Path -Path "$($_.Path)\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log" -Type Leaf -ErrorAction SilentlyContinue) {
$FeedbackLogLocations.Add(
[PSCustomObject]@{
Name = $_.Name
Domain = $_.Domain
LogFileLocation = "$($_.Path)\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log"
}
)
return
}
if (Test-Path -Path "C:\Users\$($_.Name)\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log" -Type Leaf -ErrorAction SilentlyContinue) {
$FeedbackLogLocations.Add(
[PSCustomObject]@{
Name = $_.Name
Domain = $_.Domain
LogFileLocation = "C:\Users\$($_.Name)\AppData\Local\NinjaOne-DEX-Survey-Script\UserDeviceFeedback.log"
}
)
return
}
# Log a message if no feedback log file is found for the user
Write-Host -Object "No feedback log file found for $($_.Name) in the domain $($_.Domain)."
}
# Unload user registry hives if they were loaded during this script execution
if ($ProfileWasLoaded.Count -gt 0) {
ForEach ($UserProfile in $ProfileWasLoaded) {
[gc]::Collect()
Start-Sleep 1
Start-Process -FilePath "cmd.exe" -ArgumentList "/C reg.exe UNLOAD HKU\$($UserProfile.SID)" -Wait -WindowStyle Hidden | Out-Null
}
}
# Log a warning if no feedback log files were found
if ($FeedbackLogLocations.Count -lt 1) {
Write-Host -Object "[Warning] No feedback was found."
}
Write-Host -Object ""
# Initialize a list to store all feedback entries
$AllFeedback = New-Object System.Collections.Generic.List[object]
$FeedbackLogLocations | ForEach-Object {
$LogFileLocation = $_.LogFileLocation
# Check the feedback log file for user feedback
Write-Host -Object "Checking '$LogFileLocation' for user feedback."
try {
# Read the first line of the log file to ensure it exists
Get-Content -Path $LogFileLocation -TotalCount 1 -ErrorAction Stop | Out-Null
} catch {
# Log an error if unable to read the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the feedback log file contents for the user '$($_.Name)'."
$ExitCode = 1
return
}
# Parse the feedback log file contents
$i = 0
Get-Content -Path $LogFileLocation -Tail 200000 | ForEach-Object {
$i++
$DataPoints = $_ -split '\|', 6
# Warn if a line has fewer than 6 data points
if ($DataPoints.Count -lt 6) {
Write-Host -Object "[Warning] Only $($DataPoints.Count) data points have been found for line '$i'."
return
}
# Parse the timestamp from the log entry
try {
$CurrentTimeStamp = [datetime]::ParseExact($DataPoints[0], 'yyyy-MM-dd HH:mm:ssK', $null)
} catch {
Write-Host -Object "[Warning] $($_.Exception.Message)"
Write-Host -Object "[Warning] Failed to parse the date '$($DataPoints[0])' from the feedback log."
}
# Decode the feedback message from Base64
try {
$UserMessage = ConvertFrom-UrlSafeBase64 -UrlSafeBase64 $DataPoints[5] -ErrorAction Stop
} catch {
Write-Host -Object "[Warning] $($_.Exception.Message)"
Write-Host -Object "[Warning] Failed to parse the following feedback message from the feedback log: $($DataPoints[5])"
}
# Add the parsed feedback entry to the list
$AllFeedback.Add(
[PSCustomObject]@{
TimeStamp = $CurrentTimeStamp
Username = $DataPoints[1]
Domain = $DataPoints[2]
FeedbackCollected = $DataPoints[3]
SystemPerformanceHasRun = $DataPoints[4]
Message = $UserMessage
Base64 = $DataPoints[5]
LogLocation = $LogFileLocation
}
)
}
# Filter feedback entries for the current log file
$UserFeedback = $AllFeedback | Where-Object { $_.LogLocation -eq $LogFileLocation }
# Log a warning if no feedback entries are found in the log file
if (!$UserFeedback) {
Write-Host -Object "[Warning] The log file does not contain any feedback."
Write-Host -Object ""
return
}
# Check if there is new feedback in the log file
if (!($UserFeedback | Where-Object { $_.FeedbackCollected -eq $False })) {
Write-Host -Object "There is no new feedback in this log file."
} else {
Write-Host -Object "New feedback was found."
}
Write-Host -Object ""
}
# Check if there is no feedback collected
if ($AllFeedback.Count -eq 0) {
try {
# Retrieve the current value of the custom field for the latest feedback
Write-Host -Object "Checking the current value of the custom field '$MultilineCustomFieldName'."
$CurrentCustomFieldValue = Get-NinjaProperty -Name $MultilineCustomFieldName -ErrorAction Stop
} catch {
# Log an error if unable to retrieve the custom field value
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the value from the custom field $MultilineCustomFieldName."
$ExitCode = 1
}
# Update the custom field if it is not already set to "No feedback has been given."
if ($CurrentCustomFieldValue -ne "No feedback has been given.") {
try {
Write-Host -Object "Attempting to set the custom field '$MultilineCustomFieldName'."
Set-NinjaProperty -Name $MultilineCustomFieldName -Value "No feedback has been given." -ErrorAction Stop
Write-Host -Object "Successfully saved the feedback to the custom field."
} catch {
# Log an error if unable to update the custom field
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to save to the custom field $MultilineCustomFieldName."
$ExitCode = 1
}
} else {
Write-Host -Object "The custom field is already set to 'No feedback has been given.'."
}
# Handle feedback history if the KeepFeedbackHistory switch is enabled
if ($KeepFeedbackHistory) {
try {
# Retrieve the current value of the custom field for feedback history
Write-Host -Object "`nChecking the current value of the custom field '$WYSIWYGCustomFieldName'."
$CurrentCustomFieldValue = Get-NinjaProperty -Name $WYSIWYGCustomFieldName -Type "WYSIWYG" -ErrorAction Stop
} catch {
# Log an error if unable to retrieve the custom field value
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the value from the custom field $WYSIWYGCustomFieldName."
$ExitCode = 1
}
# Trim the current value if it is not null or empty
if (![string]::IsNullOrWhiteSpace($CurrentCustomFieldValue.Text)) {
$CurrentCustomFieldValue = $CurrentCustomFieldValue.Text.Trim()
}
# Update the custom field if it is not already set to "No feedback has been given."
if ($CurrentCustomFieldValue -ne "No feedback has been given.") {
try {
Write-Host -Object "Attempting to set the custom field '$WYSIWYGCustomFieldName'."
Set-NinjaProperty -Name $WYSIWYGCustomFieldName -Value "No feedback has been given." -ErrorAction Stop
Write-Host -Object "Successfully saved the feedback history to the custom field."
} catch {
# Log an error if unable to update the custom field
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to save to the custom field $WYSIWYGCustomFieldName."
$ExitCode = 1
}
} else {
Write-Host -Object "The custom field is already set to 'No feedback has been given.'."
}
}
# Exit the script with the current exit code
exit $ExitCode
}
# Compile the most recent feedback
Write-Host -Object "Compiling the most recent feedback."
if ($AllFeedback.Count -gt 1) {
# Sort feedback by timestamp in descending order
$AllFeedback = [System.Collections.Generic.List[object]]::new(($AllFeedback | Sort-Object -Property "Timestamp" -Descending))
}
# Initialize lists for most recent feedback and collected feedback
$MostRecentFeedback = New-Object System.Collections.Generic.List[object]
$FeedbackCollected = New-Object System.Collections.Generic.List[object]
# Group feedback by log location and retrieve the most recent entry for each user
$FeedbackPerUser = $AllFeedback | Group-Object -Property "LogLocation"
$FeedbackPerUser | ForEach-Object {
$MostRecentFeedback.Add(
(
$_.Group | Sort-Object -Property TimeStamp | Select-Object -Last 1
)
)
}
# Check if all the most recent feedback has already been collected
if (!($MostRecentFeedback | Where-Object { $_.FeedbackCollected -eq $False })) {
Write-Host -Object "All the most recent feedback has already been collected.`n"
}
# Process new feedback if available
if ($MostRecentFeedback | Where-Object { $_.FeedbackCollected -eq $False }) {
$LatestUserFeedbackValue = New-Object System.Collections.Generic.List[String]
if ($MostRecentFeedback.Count -gt 1) {
# Sort most recent feedback by timestamp in descending order
$MostRecentFeedback = [System.Collections.Generic.List[object]]::new(($MostRecentFeedback | Sort-Object -Property "TimeStamp" -Descending))
}
# Format the feedback entries for display
$MostRecentFeedback | ForEach-Object {
$DateString = "$($_.TimeStamp.ToShortDateString()) $($_.TimeStamp.ToShortTimeString())"
$LatestUserFeedbackValue.Add("$DateString | $($_.Username) | $($_.Domain)")
$LatestUserFeedbackValue.Add([String]$_.Message)
$LatestUserFeedbackValue.Add("")
}
try {
# Check if the character limit for the custom field is exceeded
$Characters = ($LatestUserFeedbackValue | Out-String) | ConvertTo-Json | Measure-Object -Character | Select-Object -ExpandProperty Characters
if ($Characters -ge 9500) {
Write-Host -Object "[Warning] The character limit of 10,000 has been reached! Trimming the output until the character limit is satisfied."
$TrimStart = Get-Date
do {
# Notify the user that data is being truncated
$LatestUserFeedbackValue = New-Object System.Collections.Generic.List[String]
$LatestUserFeedbackValue.Add("This info has been truncated to accommodate the 10,000 character limit.")
$LatestUserFeedbackValue.Add("")
# Calculate the batch size for trimming
$ExceededAmount = $Characters - 9500
if ($ExceededAmount -le 0) {
$BatchSize = 1
} else {
$BatchSize = [math]::Ceiling($ExceededAmount / 500)
}
# Remove excess feedback entries
for ($i = 0 ; $i -lt $BatchSize ; $i++) {
$MostRecentFeedback.RemoveAt($MostRecentFeedback.Count - 1)
}
# Reformat the remaining feedback entries
$MostRecentFeedback | ForEach-Object {
$DateString = "$($_.TimeStamp.ToShortDateString()) $($_.TimeStamp.ToShortTimeString())"
$LatestUserFeedbackValue.Add("$DateString | $($_.Username) | $($_.Domain)")
$LatestUserFeedbackValue.Add([String]$_.Message)
$LatestUserFeedbackValue.Add("")
}
# Recalculate character count and continue trimming if necessary
$Characters = ($LatestUserFeedbackValue | Out-String) | ConvertTo-Json | Measure-Object -Character | Select-Object -ExpandProperty Characters
$ElapsedTime = (Get-Date) - $TrimStart
if ($ElapsedTime.TotalMinutes -ge 5) {
Write-Host -Object "The current character count is '$Characters'."
throw "5 minute timeout reached. Unable to trim the output to comply with the character limit."
}
} while ($Characters -ge 9500)
}
# Save the most recent feedback to the custom field
Write-Host -Object "Attempting to set the custom field '$MultilineCustomFieldName'."
$LatestUserFeedbackValue | Set-NinjaProperty -Name $MultilineCustomFieldName -ErrorAction Stop
$MostRecentFeedback | ForEach-Object { $FeedbackCollected.Add($_) }
Write-Host -Object "Successfully saved the feedback to the custom field.`n"
} catch {
# Log an error if unable to save the feedback
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to save the most recent feedback to the custom field '$MultilineCustomFieldName'."
$ExitCode = 1
}
}
# Check if feedback history should be kept and if all feedback entries have already been collected
if ($KeepFeedbackHistory -and !($AllFeedback | Select-Object -First $CommentsToKeep | Where-Object { $_.FeedbackCollected -eq $False })) {
Write-Host -Object "The past '$CommentsToKeep' feedback entries have already been collected.`nAttempting to verify if any update is necessary."
try {
# Retrieve the current value of the custom field for feedback history
Write-Host -Object "Checking the current value of the custom field '$WYSIWYGCustomFieldName'."
$WysiwygValue = Get-NinjaProperty -Name $WYSIWYGCustomFieldName -ErrorAction Stop
# Extract the HTML content from the custom field value if it exists
if ($WysiwygValue) {
$WysiwygHTML = $WysiwygValue | ConvertFrom-Json -ErrorAction Stop | Select-Object -ExpandProperty HTML -ErrorAction SilentlyContinue
}
} catch {
# Log an error if unable to retrieve the custom field value
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the current value of the custom field '$WYSIWYGCustomFieldName'."
$ExitCode = 1
}
# Calculate the expected number of feedback entries based on the specified limit
$ExpectedNumberOfEntries = $AllFeedback | Select-Object -First $CommentsToKeep | Measure-Object -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Count -ErrorAction SilentlyContinue
# Check if the current HTML content matches the expected number of entries
if ($WysiwygHTML) {
# Count the number of entries in the current HTML content
$WysiwygEntries = $WysiwygHTML -split "<tr>" | Where-Object { $_ -match "</td>" } | Measure-Object -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Count -ErrorAction SilentlyContinue
# Determine if an update is necessary based on the mismatch in entry counts
if ($WysiwygEntries -ne $ExpectedNumberOfEntries) {
Write-Host -Object "There are currently '$WysiwygEntries' entries when '$ExpectedNumberOfEntries' entries were expected. An update is necessary."
$NeedToUpdateWysiwyg = $True
}
} else {
# Log a message if no entries exist in the current HTML content
Write-Host -Object "There are currently no entries when '$ExpectedNumberOfEntries' entries were expected. An update is necessary."
$NeedToUpdateWysiwyg = $True
}
# Log a message if no update is necessary
if (!$NeedToUpdateWysiwyg) {
Write-Host -Object "The custom field '$WYSIWYGCustomFieldName' contains all '$ExpectedNumberOfEntries' expected entries. No update is necessary."
}
Write-Host -Object ""
}
# Check if feedback history should be kept and if there is new feedback to process
if ($KeepFeedbackHistory -and (($AllFeedback | Select-Object -First $CommentsToKeep | Where-Object { $_.FeedbackCollected -eq $False }) -or $NeedToUpdateWyswiyg)) {
# Initialize a list to store the feedback history in HTML format
$FeedbackHistoryValue = New-Object System.Collections.Generic.List[String]
# Begin the HTML structure
$FeedbackHistoryValue.Add("<div>")
# Convert feedback data into an HTML table
$HTMLTable = $AllFeedback | Select-Object -First $CommentsToKeep | Select-Object @{ Name = 'Feedback Date'; Expression = {
"$($_.TimeStamp.ToShortDateString()) $($_.TimeStamp.ToShortTimeString())"
}
}, Username, Domain, Message | ConvertTo-Html -Fragment
# Apply styling to the HTML table headers
$HTMLTable = $HTMLTable -replace '<th>', "<th><b>" -replace '<\/th>', "</b></th>"
$HTMLTable = $HTMLTable -replace '<th><b>Feedback Date', "<th style='width: 15em'><b>Feedback Date"
$HTMLTable = $HTMLTable -replace '<th><b>Username', "<th style='width: 15em'><b>Username"
$HTMLTable = $HTMLTable -replace '<th><b>Domain', "<th style='width: 15em'><b>Domain"
# Wrap the table in a styled HTML card
$HTMLCard = "<div class='card flex-grow-1'>
<div class='card-title-box'>
<div class='card-title'><i class='fa-solid fa-comment'></i> User Feedback History</div>
</div>
<div class='card-body' style='white-space: nowrap'>
<div>
<br>
$HTMLTable
</div>
</div>
</div>"
# Add the card to the feedback history
$FeedbackHistoryValue.Add($HTMLCard)
$FeedbackHistoryValue.Add("</div>")
try {
# Check if the character limit for the custom field is exceeded
$Characters = ($FeedbackHistoryValue | Out-String).Length
if ($Characters -ge 190000) {
Write-Host -Object "The current character count is '$Characters'."
Write-Host -Object "[Warning] The character limit of 200,000 has been reached! Trimming the output until the character limit is satisfied."
$TrimStart = Get-Date
do {
# Notify the user that data is being truncated
$FeedbackHistoryValue = New-Object System.Collections.Generic.List[String]
$FeedbackHistoryValue.Add("<div>")
$FeedbackHistoryValue.Add("<h1>This information has been truncated to fit within the character limit of 200,000.</h1>")
# Calculate the batch size for trimming
$ExceededAmount = $Characters - 190000
if ($ExceededAmount -le 0) {
$BatchSize = 1
} else {
$BatchSize = [math]::Ceiling($ExceededAmount / 500)
}
# Remove excess feedback entries
for ($i = 0 ; $i -lt $BatchSize ; $i++) {
$AllFeedback.RemoveAt($AllFeedback.Count - 1)
}
# Rebuild the HTML table with the remaining feedback
$HTMLTable = $AllFeedback | Select-Object -First $CommentsToKeep | Select-Object @{ Name = 'Feedback Date'; Expression = {
"$($_.TimeStamp.ToShortDateString()) $($_.TimeStamp.ToShortTimeString())"
}
}, Username, Domain, Message | ConvertTo-Html -Fragment
$HTMLTable = $HTMLTable -replace '<th>', "<th><b>" -replace '<\/th>', "</b></th>"
$HTMLTable = $HTMLTable -replace '<th><b>Feedback Date', "<th style='width: 15em'><b>Feedback Date"
$HTMLTable = $HTMLTable -replace '<th><b>Username', "<th style='width: 15em'><b>Username"
$HTMLTable = $HTMLTable -replace '<th><b>Domain', "<th style='width: 15em'><b>Domain"
# Wrap the updated table in a styled HTML card
$HTMLCard = "<div class='card flex-grow-1'>
<div class='card-title-box'>
<div class='card-title'><i class='fa-solid fa-comment'></i> User Feedback History</div>
</div>
<div class='card-body' style='white-space: nowrap'>
<div>
<br>
$HTMLTable
</div>
</div>
</div>"
# Add the updated card to the feedback history
$FeedbackHistoryValue.Add($HTMLCard)
$FeedbackHistoryValue.Add("</div>")
# Recalculate character count and continue trimming if necessary
$Characters = ($FeedbackHistoryValue | Out-String).Length
$ElapsedTime = (Get-Date) - $TrimStart
if ($ElapsedTime.TotalMinutes -ge 5) {
Write-Host -Object "The current character count is '$Characters'."
throw "5 minute timeout reached. Unable to trim the output to comply with the character limit."
}
} while ($Characters -ge 190000)
}
# Save the feedback history to the custom field
Write-Host -Object "Attempting to save the feedback to the custom field '$WYSIWYGCustomFieldName'."
$FeedbackHistoryValue | Set-NinjaProperty -Name $WYSIWYGCustomFieldName -Type "WYSIWYG" -ErrorAction Stop
$AllFeedback | Select-Object -First $CommentsToKeep | Where-Object { $FeedbackCollected -notcontains $_ } | ForEach-Object { $FeedbackCollected.Add($_) }
Write-Host -Object "Successfully saved the feedback history to the custom field.`n"
} catch {
# Log an error if unable to save the feedback history
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to save the feedback history to the custom field '$WYSIWYGCustomFieldName'.`n"
$ExitCode = 1
}
}
# Exit if no feedback was collected
if ($FeedbackCollected.Count -eq 0) {
exit $ExitCode
}
# Update feedback logs to mark feedback as collected
Write-Host -Object "Updating the feedback log(s) to indicate that the feedback has been collected."
$LogsToUpdate = $FeedbackCollected | Group-Object -Property "LogLocation"
$LogsToUpdate | ForEach-Object {
try {
# Retrieve the log file content
$LogPath = $_.Name
$LogContent = Get-Content -Path $LogPath -ErrorAction Stop
} catch {
# Log an error if unable to retrieve the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the log file at '$($_.Name)'."
$ExitCode = 1
return
}
try {
# Create a map of updates for the log file
$UpdateMap = @{}
$_.Group | ForEach-Object {
$Key = "$($_.TimeStamp.ToString('yyyy-MM-dd HH:mm:ssK'))|$($_.Username)|$($_.Domain)|$($_.FeedbackCollected)"
$Message = $_.Base64
$UpdateMap["$Key;$Message"] = "$($_.TimeStamp.ToString('yyyy-MM-dd HH:mm:ssK'))|$($_.Username)|$($_.Domain)|$True"
}
# Update the log file content with the collected feedback status
$LogContent = $LogContent | ForEach-Object {
$Line = $_
foreach ($UpdateKey in $UpdateMap.Keys) {
$EscapedKey = [regex]::Escape(($UpdateKey -split ';', 2)[0])
$EscapedMsg = [regex]::Escape(($UpdateKey -split ';', 2)[1])
if ($Line -match $EscapedKey -and $Line -match $EscapedMsg) {
$Line = $Line -replace $EscapedKey, $UpdateMap[$UpdateKey]
}
}
$Line
}
# Save the updated log file content
Set-Content -Path $LogPath -Value $LogContent -ErrorAction Stop
Write-Host -Object "Updated the log file at '$($_.Name)'."
} catch {
# Log an error if unable to update the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to update the log file at '$($_.Name)'."
$ExitCode = 1
}
}
# Clean up log files to retain only the specified number of comments
Write-Host -Object "`nCleaning up the log files so only the previous '$CommentsToKeep' are kept."
$FeedbackLogLocations | ForEach-Object {
try {
# Retrieve the log file content
$LogPath = $_.LogFileLocation
$LogContent = Get-Content -Path $LogPath -ErrorAction Stop
} catch {
# Log an error if unable to retrieve the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to retrieve the log file at '$($_.LogFileLocation)'."
$ExitCode = 1
return
}
try {
# Trim the log file content if it exceeds the specified number of comments
if ($LogContent.Count -le $CommentsToKeep) {
Write-Host -Object "The log file at '$LogPath' already has less than or equal to '$CommentsToKeep' comments."
} else {
Set-Content -Path $LogPath -Value ($LogContent | Select-Object -Last $CommentsToKeep) -ErrorAction Stop
Write-Host -Object "Updated the log file at '$LogPath'."
}
} catch {
# Log an error if unable to update the log file
Write-Host -Object "[Error] $($_.Exception.Message)"
Write-Host -Object "[Error] Failed to update the log file at '$($_.LogFileLocation)'."
$ExitCode = 1
}
}
exit $ExitCode
}
end {
}
Description détaillée
Le script est complet et méthodiquement conçu pour être fiable et flexible. Voici comment :
1. Initialisation et validation
- Valide la compatibilité de la version du système d’exploitation (Windows 10/Server 2016 et plus récent).
- Vérifie les privilèges administratifs.
- Valide et assainit les paramètres d’entrée tels que MaxCommentsToKeep, KeepFeedbackHistory et les noms de champs personnalisés.
2. Énumération des profils d’utilisateurs
- Utilise la fonction Get-UserHive pour énumérer tous les profils d’utilisateurs (domaine, local, AzureAD).
- Charge les ruches (hives) du registre lorsque cela est nécessaire pour extraire les chemins d’accès aux fichiers journaux appropriés.
3. Découverte du journal des avis
- Recherche dans les chemins d’accès standard AppData\Local les fichiers journaux de retour d’information nommés UserDeviceFeedback.log.
4. Extraction et analyse des avis
- Lit les fichiers journaux ligne par ligne.
- Chaque ligne est divisée en points de données tels que l’horodatage, le nom d’utilisateur, le domaine et l’avis encodé en base64.
- Le message est décodé et ajouté à une collection d’entrées de retour d’information.
5. Mise à jour des champs personnalisés
- Les avis les plus récents non collectés, en fonction de l’utilisateur, sont compilés.
- Enregistre dans le Dernier avis de l’utilisateur (champ multiligne) s’il respecte la limite de 10 000 caractères.
- Si cette option est activée, un tableau HTML formaté des dernières entrées est créé et enregistré dans l’historique des avis des utilisateurs (champ WYSIWYG), dans le respect d’une limite de 200 000 caractères.
6. Mises à jour du fichier journal
- Marque les entrées d’avis traitées comme étant « collectées ».
- Élimine les entrées les plus anciennes au-delà du maximum spécifié afin de maintenir la taille du journal.
7. Gestion des limites de caractères
- Il élimine automatiquement les anciens avis si les limites du champ sont dépassées, ce qui garantit que les données ne sont pas tronquées au milieu de l’enregistrement.
Cette approche structurée permet de ne recueillir que des informations nouvelles et pertinentes et d’éviter les traitements redondants.
Cas d’utilisation potentiels
Scénario : Un fournisseur de services gérés (MSP) de taille moyenne gère 500 terminaux répartis entre plusieurs clients. Après avoir déployé une mise à jour des performances, ils souhaitent évaluer son impact sur l’expérience des utilisateurs.
- Étape 1 : Déployer le script 1 sur tous les appareils pour inviter les utilisateurs à donner leur avis.
- Étape 2 : Utiliser le script 2 pour consolider les réponses dans NinjaOne.
- Étape 3 : Examiner les réponses de manière centralisée et faire remonter les appareil avec un avis est négatif.
Cela permet de créer une boucle de rétroaction fermée sans intervention humaine, ce qui simplifie le dépannage et améliore la satisfaction des clients.
Comparaisons
Il existe d’autres méthodes pour recueillir les avis des utilisateurs dans Windows avec un script PowerShell :
- Écrire directement sur un fichier central partagé (risque de problèmes d’autorisation).
- Utilisation des journaux d’événements (analyse complexe).
- Envoi des journaux par e-mail (non sécurisé et non extensible).
En comparaison, l’intégration de NinjaOne se fait par l’intermédiaire de champs personnalisés :
- Garantit un stockage des données sécurisé et lié à l’appareil.
- Facilite l’accès à la console NinjaOne.
- Permet l’automatisation et l’établissement de rapports via les flux de travail NinjaOne.
Questions fréquentes
Question 1 : Que se passe-t-il si aucun avis n’est trouvé ?
Le script met à jour le champ personnalisé avec la mention « Aucun avis n’a été donné » ( “No feedback has been given”)
Question 2 : Que se passe-t-il lorsque les avis dépassent la limite de caractères ?
Le script supprime automatiquement les entrées les plus anciennes et notifie le contenu du champ.
Question 3 : Peut-on l’utiliser sans le script 1 ?
Non, le script 2 dépend des journaux de retour d’information générés par le script 1.
Question 4 : Prend-il en charge les utilisateurs AzureAD ?
Oui, il détecte les profils d’utilisateurs AzureAD, locaux et de domaine.
Question 5 : Les balises HTML sont-elles nécessaires dans le champ WYSIWYG ?
Oui, le script génère automatiquement un tableau HTML complet avec une mise en forme pour une meilleure lisibilité.
Implications
L’automatisation de la collecte des avis a des implications considérables. En recueillant et en archivant continuellement les avis, les départements informatiques peuvent :
- Détecter rapidement les régressions de performance.
- Identifier les problèmes récurrents.
- Justifier les investissements dans les infrastructures à l’aide de données réelles sur les sentiments des utilisateurs.
De plus, le stockage sécurisé de ces informations dans NinjaOne renforce la conformité des données et les normes de gouvernance.
Recommandations
- Exécutez le script 2 selon un calendrier (par exemple, tous les jours) en utilisant les scripts planifiés de NinjaOne.
- Pour une pleine fonctionnalité, il faut toujours l’associer au Script 1 .
- Définissez des valeurs réalistes pour MaxCommentsToKeep afin d’éviter d’inonder les journaux.
- Activez KeepFeedbackHistory si une analyse des tendances à long terme est nécessaire.
- Surveillez la taille des champs pour éviter que les données ne soient tronquées en raison des limites de la plateforme.
Conclusion
Ce script illustre comment recueillir les avis des utilisateurs dans Windows avec un script PowerShell et l’intégrer dans un écosystème RMM tel que NinjaOne. Pour les professionnels de l’informatique et les MSP qui s’efforcent de fournir un support proactif et centré sur l’utilisateur, cette automatisation réduit les frais généraux, améliore la visibilité et permet de prendre des décisions fondées sur des données. En exploitant les capacités de script et les champs personnalisés de NinjaOne, la vérification des avis n’est plus un élément à cocher, mais un pilier de la qualité du service informatique.