The other day I shared part of my solution to an Iron Scripter challenge to write a generic function to report on the age of an object. The idea being that you could pipe any type of object to the function and get a result. And because I can't help myself, I went a bit further with my solution.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
One of the things that nagged me about my original function, is that I needed to specify the property names for the creation and last modified time. Except, I know what those properties will be for common object types such as files and processes. So why not make it easier?
Using a JSON Configuration File
I created a JSON file with my common object types and associated properties.
[ { "TypeName": "Process", "Created": "StartTime", "Modified": "StartTime", "Properties": [ "Name", "ID", "WS", "Handles" ] }, { "TypeName": "File", "Created": "CreationTime", "Modified": "LastWriteTime", "Properties": [ "Name", "FullName", "Length", { "Name" : "SizeKB", "Expression" : "[math]::Round($_.length/1KB,2)" }, { "Name" : "Owner", "Expression" : "(Get-Acl $_.fullname).owner" } ] }, { "TypeName": "ADUser", "Created": "Created", "Modified": "Modified", "Properties": [ "DistinguishedName", "Name", "SAMAccountName" ] }, { "TypeName": "ADGroup", "Created": "Created", "Modified": "Modified", "Properties": [ "DistinguishedName", "Name", "GroupCategory", "GroupScope" ] }, { "TypeName": "ADComputer", "Created": "Created", "Modified": "Modified", "Properties": [ "DistinguishedName", "Name", "DNSHostName" ] } ]
The file is called objectage-types.json. The TypeName is the identifier. I'll show you how that comes into play in a minute. Each object type has a setting for the Created and Modified properties. Process objects don't have a modified property so I'll just use the Created property. Each object type also has an array of default properties. I can even use a custom property hashtable.
In the ps1 file, I have this line of code to run when you dot source the file.
$global:ObjectAgeData = Get-Content $PSScriptRoot\objectage-types.json | ConvertFrom-Json
I would do things a bit different if this was packaged as a module.
My parameters now include parameter sets.
Function Get-ObjectAge2 { [cmdletbinding(DefaultParameterSetName = "custom")] [alias('goa2')] Param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullorEmpty()] [object[]]$InputObject, #provider a helper [Parameter(Mandatory, ParameterSetName = "named")] [ValidateSet("File", "Process", "ADUser", "ADComputer", "ADGroup")] [string]$ObjectType, [Parameter(Mandatory, Position = 0, ParameterSetName = "custom")] [string]$CreationProperty, [Parameter(Position = 1, ParameterSetName = "custom")] [string]$ChangeProperty, [Parameter(ParameterSetName = "custom")] [object[]]$Properties = @("Name") )
The default is what I used in the original version of this function. The other parameter set uses a parameter called ObjectType. Each item in the validation set corresponds to an object in the JSON file.
In the function I load the data from the global variable.
if ($pscmdlet.ParameterSetName -eq 'named') { Write-Verbose "[BEGIN ] Using user specified object type of $ObjectType" #get values from the global data variable $data = $global:ObjectAgeData | Where-Object {$_.Typename -eq $ObjectType} $CreationProperty = $data.Created $ChangeProperty = $data.Modified $Properties = $data.Properties $tname = "objAge.$ObjectType" }
The $tname variable will be a custom object type name. You'll see how that comes into play below.
Creating a Custom Object
The process if importing each object is unchanged. I also use the ANSI progress spinner. Now for each item, I'm creating a custom property and inserting the type name.
foreach ($item in $all) { $Age = New-TimeSpan -end $Now -Start ($item.$creationProperty) $tmpHash = [ordered]@{ PSTypeName = $tname Created = $item.$CreationProperty Modified = $item.$ChangeProperty Age = $Age Average = If ($Age -gt $Avg) {"Above"} else {"Below"} AverageAll = $avg } foreach ($prop in $Properties) { if ($prop.gettype().name -eq 'hashtable') { Write-Verbose "[END ] Expanding hashtable" $custom = $item | Select-object -property $prop $prop = $custom.psobject.properties.Name $val = $custom.psobject.properties.Value } elseif ($prop.gettype().name -eq "PSCustomObject") { Write-Verbose "[END ] Expanding custom object probably from a json file" #reconstruct the hashtable $tmpProp = @{Name = "$($prop.name)";Expression = $([scriptblock]::create($prop.Expression))} $custom = $item | Select-object -property $tmpProp $prop = $custom.psobject.properties.Name $val = $custom.psobject.properties.Value } else { $val = $item.$prop } Write-Verbose "[END ] Adding property $prop $val" $tmpHash.Add($prop, $val) } #foreach prop #create the object New-Object -TypeName PSObject -property $tmpHash } #foreach item
I had to add some code to recreate property hashtables from the JSON file since everything is treated as a string.
The reason I added a new custom type name, is so that I can format the results. At least by default. I built a format.ps1xml file using the New-PSFormatXML command from the PSScriptTools module.
<?xml version="1.0" encoding="UTF-8"?> <!-- format type data generated 05/26/2020 15:50:58 by BOVINE320\Jeff This file is incomplete. It doesn't have all of the defined object types in Get-ObjectAge2. Consider this file a proof of concept or work-in-progress. --> <Configuration> <ViewDefinitions> <View> <!--Created 05/26/2020 15:50:58 by BOVINE320\Jeff--> <Name>default</Name> <ViewSelectedBy> <TypeName>objAge.Process</TypeName> </ViewSelectedBy> <TableControl> <!--Delete the AutoSize node if you want to use the defined widths. <AutoSize />--> <TableHeaders> <TableColumnHeader> <Label>Created</Label> <Width>21</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Modified</Label> <Width>21</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Age</Label> <Width>11</Width> <Alignment>right</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Avg</Label> <Width>5</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Name</Label> <Width>23</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>ID</Label> <Width>7</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>WS</Label> <Width>11</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Handles</Label> <Width>10</Width> <Alignment>left</Alignment> </TableColumnHeader> </TableHeaders> <TableRowEntries> <TableRowEntry> <TableColumnItems> <!-- By default the entries use property names, but you can replace them with scriptblocks. <ScriptBlock>$_.foo /1mb -as [int]</ScriptBlock> --> <TableColumnItem> <PropertyName>Created</PropertyName> </TableColumnItem> <TableColumnItem> <PropertyName>Modified</PropertyName> </TableColumnItem> <TableColumnItem> <ScriptBlock> $_.Age.ToString("d'.'hh':'mm':'ss") </ScriptBlock> </TableColumnItem> <TableColumnItem> <ScriptBlock> if ($_.Average -eq 'Above') { "$([char]0x1b)[91m$($_.average)$([char]0x1b)[0m" } else { $_.Average } </ScriptBlock> </TableColumnItem> <TableColumnItem> <PropertyName>Name</PropertyName> </TableColumnItem> <TableColumnItem> <PropertyName>ID</PropertyName> </TableColumnItem> <TableColumnItem> <PropertyName>WS</PropertyName> </TableColumnItem> <TableColumnItem> <PropertyName>Handles</PropertyName> </TableColumnItem> </TableColumnItems> </TableRowEntry> </TableRowEntries> </TableControl> </View> <View> <!--Created 05/26/2020 16:01:55 by BOVINE320\Jeff--> <Name>default</Name> <ViewSelectedBy> <TypeName>objAge.File</TypeName> </ViewSelectedBy> <GroupBy> <PropertyName>AverageAll</PropertyName> <Label>Overall Average Creation Age</Label> </GroupBy> <TableControl> <!--Delete the AutoSize node if you want to use the defined widths. <AutoSize />--> <TableHeaders> <TableColumnHeader> <Label>Created</Label> <Width>24</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Modified</Label> <Width>23</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Age</Label> <Width>15</Width> <Alignment>Right</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Average</Label> <Width>10</Width> <Alignment>center</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Name</Label> <Width>24</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>SizeKB</Label> <Width>9</Width> <Alignment>right</Alignment> </TableColumnHeader> </TableHeaders> <TableRowEntries> <TableRowEntry> <TableColumnItems> <!-- By default the entries use property names, but you can replace them with scriptblocks. <ScriptBlock>$_.foo /1mb -as [int]</ScriptBlock> --> <TableColumnItem> <PropertyName>Created</PropertyName> </TableColumnItem> <TableColumnItem> <PropertyName>Modified</PropertyName> </TableColumnItem> <TableColumnItem> <ScriptBlock> $_.Age.ToString("d'.'hh':'mm':'ss") </ScriptBlock> </TableColumnItem> <TableColumnItem> <ScriptBlock> if ($_.Average -eq 'Above') { "$([char]0x1b)[91m$($_.average)$([char]0x1b)[0m" } else { $_.Average } </ScriptBlock> </TableColumnItem> <TableColumnItem> <PropertyName>Name</PropertyName> </TableColumnItem> <TableColumnItem> <PropertyName>SizeKB</PropertyName> </TableColumnItem> </TableColumnItems> </TableRowEntry> </TableRowEntries> </TableControl> </View> <View> <!--Created 05/26/2020 16:09:07 by BOVINE320\Jeff--> <Name>default</Name> <ViewSelectedBy> <TypeName>objAge.ADUser</TypeName> </ViewSelectedBy> <GroupBy> <PropertyName>AverageAll</PropertyName> <Label>Overall Average Creation Age</Label> </GroupBy> <TableControl> <!--Delete the AutoSize node if you want to use the defined widths. <AutoSize />--> <TableHeaders> <TableColumnHeader> <Label>Created</Label> <Width>23</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Modified</Label> <Width>22</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Age</Label> <Width>15</Width> <Alignment>right</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>Avg</Label> <Width>5</Width> <Alignment>left</Alignment> </TableColumnHeader> <TableColumnHeader> <Label>DistinguishedName</Label> <Width>46</Width> <Alignment>left</Alignment> </TableColumnHeader> </TableHeaders> <TableRowEntries> <TableRowEntry> <TableColumnItems> <!-- By default the entries use property names, but you can replace them with scriptblocks. <ScriptBlock>$_.foo /1mb -as [int]</ScriptBlock> --> <TableColumnItem> <PropertyName>Created</PropertyName> </TableColumnItem> <TableColumnItem> <PropertyName>Modified</PropertyName> </TableColumnItem> <TableColumnItem> <ScriptBlock> $_.Age.ToString("d'.'hh':'mm':'ss") </ScriptBlock> </TableColumnItem> <TableColumnItem> <ScriptBlock> if ($_.Average -eq 'Above') { "$([char]0x1b)[91m$($_.average)$([char]0x1b)[0m" } else { $_.Average } </ScriptBlock> </TableColumnItem> <TableColumnItem> <PropertyName>DistinguishedName</PropertyName> </TableColumnItem> </TableColumnItems> </TableRowEntry> </TableRowEntries> </TableControl> </View> </ViewDefinitions> </Configuration>
I load this file in the .ps1 file.
Update-FormatData $PSScriptRoot\objage.format.ps1xml
Now I can easily get formatted results for my common object types.
I almost forgot. My format file includes ANSI formatting for the Avg property.
When querying Active Directory, I have to remember to include the necessary properties.
For this object I added a custom GroupBy property to use the AverageAll value. That property is part of the custom object.
And I can still use the custom way for everything else.
$cim = @{ Namespace = "root/virtualization/v2" ClassName = "Msvm_ComputerSystem" filter = "caption='Virtual Machine'" } $goa = @{ CreationProperty = "InstallDate" ChangeProperty = "TimeOfLastStateChange" Properties = @{Name="VM";Expression={$_.ElementName}}, @{Name="Uptime";Expression = {New-TimeSpan -Start (Get-Date).AddMilliseconds(-$_.ontimeInMilliseconds) -End (Get-Date)}} } Get-Ciminstance @cim | Get-ObjectAge2 @goa
The Code
Here's the complete function and relevant commands.
Function Get-ObjectAge2 { [cmdletbinding(DefaultParameterSetName = "custom")] [alias('goa2')] Param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullorEmpty()] [object[]]$InputObject, #provider a helper [Parameter(Mandatory, ParameterSetName = "named")] [ValidateSet("File", "Process", "ADUser", "ADComputer", "ADGroup")] [string]$ObjectType, [Parameter(Mandatory, Position = 0, ParameterSetName = "custom")] [string]$CreationProperty, [Parameter(Position = 1, ParameterSetName = "custom")] [string]$ChangeProperty, [Parameter(ParameterSetName = "custom")] [object[]]$Properties = @("Name") ) Begin { Write-Verbose "[BEGIN ] Starting: $($MyInvocation.Mycommand)" $all = [System.Collections.Generic.List[object]]::new() if ($pscmdlet.ParameterSetName -eq 'named') { Write-Verbose "[BEGIN ] Using user specified object type of $ObjectType" #get values from the global data variable $data = $global:ObjectAgeData | Where-Object {$_.Typename -eq $ObjectType} $CreationProperty = $data.Created $ChangeProperty = $data.Modified $Properties = $data.Properties $tname = "objAge.$ObjectType" } else { #set a default value $tname = "objAge" } #use the same date time for all timespans $Now = Get-Date Write-Verbose "[BEGIN ] Using $NOW for timespan calculations" Write-Verbose "[BEGIN ] Using $CreationProperty for CreationProperty" #use CreationProperty parameter for Changed property if latter is not specified if (-Not $ChangeProperty) { Write-Verbose "[BEGIN ] Using $CreationProperty for ChangeProperty" $changeProperty = $CreationProperty } else { Write-Verbose "[BEGIN ] Using $ChangeProperty for ChangeProperty" } #initialize counters and an array of characters to be using in a progress wheel $i = 0 $ch = @("|", "/", "-", "\", "|", "/", "-") $c = 0 } #begin Process { foreach ($object in $InputObject) { $i++ #display a progress symbol if NOT using -Verbose if (-Not ($VerbosePreference -eq "Continue")) { if ($c -ge $ch.count) { $c = 0 } Write-Host "$([char]0x1b)[1EProcessing $($ch[$c])$([char]0x1b)[0m" -ForegroundColor cyan -NoNewline $c++ Start-Sleep -Milliseconds 10 } $all.Add($object) } } #process End { Write-Verbose "[END ] Calculating average creation age for $($all.count) objects" $allTimeSpans = $all | ForEach-Object { Try { $X = $_ New-TimeSpan -start $_.$creationProperty -end $Now -ErrorAction Stop } Catch { Write-Warning "Failed to get $CreationProperty value. $($_.exception.message)" Write-Warning ($X | Out-String) } } #foreach $avg = New-TimeSpan -seconds ($allTimeSpans | Measure-Object -Property TotalSeconds -average).Average $results = foreach ($item in $all) { $Age = New-TimeSpan -end $Now -Start ($item.$creationProperty) $tmpHash = [ordered]@{ PSTypeName = $tname Created = $item.$CreationProperty Modified = $item.$ChangeProperty Age = $Age Average = If ($Age -gt $Avg) {"Above"} else {"Below"} AverageAll = $avg } foreach ($prop in $Properties) { if ($prop.gettype().name -eq 'hashtable') { Write-Verbose "[END ] Expanding hashtable" $custom = $item | Select-object -property $prop $prop = $custom.psobject.properties.Name $val = $custom.psobject.properties.Value } elseif ($prop.gettype().name -eq "PSCustomObject") { Write-Verbose "[END ] Expanding custom object probably from a json file" #reconstruct the hashtable $tmpProp = @{Name = "$($prop.name)";Expression = $([scriptblock]::create($prop.Expression))} $custom = $item | Select-object -property $tmpProp $prop = $custom.psobject.properties.Name $val = $custom.psobject.properties.Value } else { $val = $item.$prop } Write-Verbose "[END ] Adding property $prop $val" $tmpHash.Add($prop, $val) } #foreach prop #create the object New-Object -TypeName PSObject -property $tmpHash } #foreach item $results Write-Verbose "[END ] Ending: $($MyInvocation.Mycommand)" } #end } #end function #define a global variable with type information $global:ObjectAgeData = Get-Content $PSScriptRoot\objectage-types.json | ConvertFrom-Json #load the formatting file Update-FormatData $PSScriptRoot\objage.format.ps1xml
All of this should be considered a proof-of-concept. But I hope you'll try it out. Or at least look at what I wrote and why. There are a number of moving parts here. Make sure you understand how this all works. If you have any questions, please feel free to leave a comment.