Skip to content
Menu
The Lonely Administrator
  • PowerShell Tips & Tricks
  • Books & Training
  • Essential PowerShell Learning Resources
  • Privacy Policy
  • About Me
The Lonely Administrator

Solving the PowerShell Object Age Challenge – Part 2

Posted on June 10, 2020

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.

Manage and Report Active Directory, Exchange and Microsoft 365 with
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.

get-objectage2-processI 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.

get-objectage2-aduser 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

get-objectage2-vm

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.


Behind the PowerShell Pipeline

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on Mastodon (Opens in new window) Mastodon
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on Reddit (Opens in new window) Reddit
  • Print (Opens in new window) Print
  • Email a link to a friend (Opens in new window) Email

Like this:

Like Loading...

Related

reports

Powered by Buttondown.

Join me on Mastodon

The PowerShell Practice Primer
Learn PowerShell in a Month of Lunches Fourth edition


Get More PowerShell Books

Other Online Content

github



PluralSightAuthor

Active Directory ADSI Automation Backup Books CIM CLI conferences console Friday Fun FridayFun Function functions Get-WMIObject GitHub hashtable HTML Hyper-V Iron Scripter ISE Measure-Object module modules MrRoboto new-object objects Out-Gridview Pipeline PowerShell PowerShell ISE Profile prompt Registry Regular Expressions remoting SAPIEN ScriptBlock Scripting Techmentor Training VBScript WMI WPF Write-Host xml

©2026 The Lonely Administrator | Powered by SuperbThemes!
%d