Public/Invoke-LGRemoteScan.ps1

function Invoke-LGRemoteScan {
    <#
    .SYNOPSIS
        Scans multiple remote machines in parallel using Invoke-Command (WinRM).
 
    .DESCRIPTION
        Accepts computer names from the pipeline or directly. Launches parallel
        background jobs (up to ThrottleLimit concurrent) and collects Windows
        Activation, installed software, and optional EOL results from each machine.
 
    .PARAMETER ComputerName
        One or more machine names to scan. Accepts pipeline input.
 
    .PARAMETER ThrottleLimit
        Maximum number of concurrent WinRM jobs. Default: 10.
 
    .PARAMETER TimeoutSeconds
        Seconds to wait for each job before marking it timed-out. Default: 120.
 
    .PARAMETER Credential
        PSCredential to use for all remote connections.
 
    .PARAMETER WarnDays
        Days before expiry to classify as WARN. Defaults to module config.
 
    .PARAMETER IncludeEol
        Also run EOL database checks on each remote machine.
 
    .EXAMPLE
        Invoke-LGRemoteScan -ComputerName PC01,PC02,PC03
 
    .EXAMPLE
        Get-LGADComputers -OU 'OU=Workstations,DC=corp,DC=local' |
            Invoke-LGRemoteScan -ThrottleLimit 20 -IncludeEol
 
    .EXAMPLE
        'PC01','PC02' | Invoke-LGRemoteScan -Credential (Get-Credential)
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string[]]$ComputerName,

        [int]$ThrottleLimit   = 10,
        [int]$TimeoutSeconds  = 120,
        [pscredential]$Credential,
        [int]$WarnDays        = 0,
        [switch]$IncludeEol
    )

    begin {
        $allComputers = [System.Collections.Generic.List[string]]::new()

        # Serialisable snapshot of EOL DB to pass into remote scriptblocks
        $eolDbSerial = $script:LGEolDatabase | ForEach-Object {
            @{ Pattern = $_.Pattern; MatchType = $_.MatchType; EolDate = $_.EolDate }
        }

        $cfg      = Get-LGEffectiveConfig
        $warnD    = if ($WarnDays -gt 0) { $WarnDays } else { $cfg.WarnDaysBeforeExpiry }
        $inclEol  = $IncludeEol.IsPresent

        # The scriptblock that runs on each remote machine
        $remoteBlock = {
            param([int]$WarnDays, [bool]$IncludeEol, [array]$EolDb)

            # --- Windows Activation ---
            $winStatus = 'ERROR'; $winDetail = 'WMI query failed'
            try {
                $prod = Get-CimInstance -Query "SELECT LicenseStatus,GracePeriodRemaining FROM SoftwareLicensingProduct WHERE PartialProductKey IS NOT NULL AND ApplicationID='55c92734-d682-4d71-983e-d6ec3f16059f'"
                if ($prod) {
                    $row = $prod | Sort-Object LicenseStatus | Select-Object -First 1
                    $ls  = [int]$row.LicenseStatus
                    $sm  = @{0=@('Unlicensed','EXPIRED');1=@('Licensed','OK');2=@('OOBGrace','WARN');
                              3=@('OOTGrace','WARN');4=@('NonGenuineGrace','WARN');
                              5=@('Notification','WARN');6=@('ExtendedGrace','WARN')}
                    $mp  = if ($sm.ContainsKey($ls)) { $sm[$ls] } else { @('Unknown','WARN') }
                    $winDetail = if ($row.GracePeriodRemaining -gt 0) {
                        "$($mp[0]) -- Remaining: $([math]::Round($row.GracePeriodRemaining/1440,1)) days"
                    } else { $mp[0] }
                    $winStatus = $mp[1]
                }
            } catch {}

            # --- Installed Software ---
            $regPaths = @(
                'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
                'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
                'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
            )
            $today  = Get-Date
            $swRows = [System.Collections.Generic.List[PSCustomObject]]::new()
            foreach ($path in $regPaths) {
                Get-ItemProperty $path 2>$null |
                    Where-Object { $_.DisplayName -and -not $_.SystemComponent } |
                    ForEach-Object {
                        $status = 'OK'; $expInfo = ''
                        $expireDate = $null
                        foreach ($field in @('ExpirationDate','TrialExpireDate','ExpireDate','LicenseExpiry')) {
                            $val = $_.$field
                            if ($val) {
                                try { $expireDate = [datetime]$val; break } catch {}
                                if ($val -match '^\d{8}$') {
                                    try { $expireDate = [datetime]::ParseExact($val,'yyyyMMdd',$null); break } catch {}
                                }
                            }
                        }
                        if ($expireDate) {
                            $dl = ($expireDate - $today).Days
                            if ($dl -lt 0)         { $status='EXPIRED'; $expInfo="EXPIRED ($([math]::Abs($dl)) days ago)" }
                            elseif ($dl -le $WarnDays) { $status='WARN';    $expInfo="Expires: $($expireDate.ToString('yyyy-MM-dd')) ($dl days)" }
                            else                   { $expInfo="Valid: $($expireDate.ToString('yyyy-MM-dd'))" }
                        }
                        $swRows.Add([PSCustomObject]@{
                            Name       = $_.DisplayName
                            Version    = if ($_.DisplayVersion) { $_.DisplayVersion } else { '-' }
                            Publisher  = if ($_.Publisher)      { $_.Publisher }      else { 'Unknown' }
                            Status     = $status
                            ExpireInfo = $expInfo
                        })
                    }
            }
            $swRows = @($swRows | Sort-Object Name -Unique)

            # --- EOL (optional) ---
            $eolRows = [System.Collections.Generic.List[PSCustomObject]]::new()
            if ($IncludeEol -and $EolDb) {
                $today2 = Get-Date
                foreach ($sw in $swRows) {
                    foreach ($entry in $EolDb) {
                        $match = switch ($entry.MatchType) {
                            'contains'   { $sw.Name -like "*$($entry.Pattern)*" }
                            'startsWith' { $sw.Name -like "$($entry.Pattern)*"  }
                            'exact'      { $sw.Name -eq $entry.Pattern           }
                            'regex'      { $sw.Name -match $entry.Pattern        }
                            default      { $false }
                        }
                        if ($match) {
                            try {
                                $eolDate  = [datetime]::ParseExact($entry.EolDate,'yyyy-MM-dd',$null)
                                $daysLeft = ($eolDate - $today2).Days
                                $st       = if ($daysLeft -lt 0) { 'EXPIRED' } else { 'WARN' }
                                $det      = if ($daysLeft -lt 0) {
                                    "EOL: $($entry.EolDate) ($([math]::Abs($daysLeft)) days ago)"
                                } else { "EOL approaching: $($entry.EolDate) ($daysLeft days left)" }
                                $eolRows.Add([PSCustomObject]@{ Name=$sw.Name; Status=$st; Detail=$det })
                            } catch {}
                            break
                        }
                    }
                }
            }

            [PSCustomObject]@{
                ComputerName = $env:COMPUTERNAME
                WinStatus    = $winStatus
                WinDetail    = $winDetail
                Software     = $swRows
                EolFindings  = @($eolRows)
            }
        }
    }

    process {
        foreach ($c in $ComputerName) { $allComputers.Add($c.Trim()) }
    }

    end {
        if ($allComputers.Count -eq 0) { return }

        Write-Host " Scanning $($allComputers.Count) machine(s) in parallel (throttle: $ThrottleLimit)..." -ForegroundColor Cyan

        $jobs    = [ordered]@{}   # pc -> job
        $queue   = [System.Collections.Generic.Queue[string]]::new()
        $allComputers | ForEach-Object { $queue.Enqueue($_) }
        $results = [System.Collections.Generic.List[PSCustomObject]]::new()

        while ($queue.Count -gt 0 -or $jobs.Count -gt 0) {

            # Launch up to ThrottleLimit jobs
            while ($queue.Count -gt 0 -and $jobs.Count -lt $ThrottleLimit) {
                $pc        = $queue.Dequeue()
                $jobParams = @{
                    ComputerName = $pc
                    ScriptBlock  = $remoteBlock
                    ArgumentList = $warnD, $inclEol, $eolDbSerial
                    AsJob        = $true
                    ErrorAction  = 'SilentlyContinue'
                }
                if ($Credential) { $jobParams.Credential = $Credential }
                try {
                    $jobs[$pc] = Invoke-Command @jobParams
                    Write-Verbose "Job launched: $pc"
                } catch {
                    $results.Add([PSCustomObject]@{
                        ComputerName = $pc; Module = 'Connection'
                        Name         = "$pc scan"; Status = 'ERROR'
                        Detail       = $_.Exception.Message; Version = ''; Publisher = ''
                    })
                }
            }

            # Collect finished jobs
            $done = @($jobs.Keys | Where-Object { $jobs[$_].State -in @('Completed','Failed') })
            foreach ($pc in $done) {
                $job = $jobs[$pc]
                try {
                    $data = Receive-Job $job -ErrorAction Stop
                    if ($data) {
                        $results.Add([PSCustomObject]@{
                            ComputerName = $pc; Module = 'WindowsActivation'
                            Name         = 'Windows Activation'
                            Status       = $data.WinStatus; Detail = $data.WinDetail
                            Version = ''; Publisher = ''
                        })
                        foreach ($sw in $data.Software) {
                            $results.Add([PSCustomObject]@{
                                ComputerName = $pc; Module = 'Software'
                                Name         = $sw.Name; Version = $sw.Version
                                Publisher    = $sw.Publisher; Status = $sw.Status
                                Detail       = $sw.ExpireInfo
                            })
                        }
                        foreach ($eol in $data.EolFindings) {
                            $results.Add([PSCustomObject]@{
                                ComputerName = $pc; Module = 'EOL'
                                Name         = $eol.Name; Status = $eol.Status
                                Detail       = $eol.Detail; Version = ''; Publisher = ''
                            })
                        }
                        Write-Host " [$pc] done — $(@($data.Software).Count) apps" -ForegroundColor DarkGray
                    } elseif ($job.State -eq 'Failed') {
                        $results.Add([PSCustomObject]@{
                            ComputerName = $pc; Module = 'Connection'
                            Name         = "$pc scan"; Status = 'ERROR'
                            Detail       = 'Job failed'; Version = ''; Publisher = ''
                        })
                        Write-Host " [$pc] ERROR — job failed" -ForegroundColor Red
                    }
                } catch {
                    $results.Add([PSCustomObject]@{
                        ComputerName = $pc; Module = 'Connection'
                        Name         = "$pc scan"; Status = 'ERROR'
                        Detail       = $_.Exception.Message; Version = ''; Publisher = ''
                    })
                    Write-Host " [$pc] ERROR — $($_.Exception.Message)" -ForegroundColor Red
                }
                Remove-Job $job -Force -ErrorAction SilentlyContinue
                $jobs.Remove($pc)
            }

            if ($queue.Count -gt 0 -or $jobs.Count -gt 0) { Start-Sleep -Milliseconds 500 }
        }

        $results
    }
}