Current Version:

# HyperVCapacityReport.ps1
# Version 0.0.2

# Report parameters
$workingDirectory='C:\scripts\googleSheets'
$selectFields='node,model,os,cores,cpuUtilizationPercent,ramGb,ramUsedPercent'
$domainObjects=@(
    @{domain='intranet.kimconnect.com';dc='lax-dc02.intranet.kimconnect.com';username='intranet\clusterReport';password=$env:clusterReportPass}
    @{domain='intranet.dragoncoin.com';dc='lax-dc02.intranet.dragoncoin.com';username='intranet\clusterReport';password=$env:clusterReportPass}
    )

# Update this Google Sheets
$spreadsheetID='SOMEHASHHERE'
$spreadsheetUrl='https://docs.google.com/spreadsheets/d/'+$spreadsheetID

# Google API Authozation
$scope = "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file"
$certPath = 'C:\scripts\googleSheets\plated-campaign-SOMEHASHERE.p12'
$iss = '[email protected]'
$certPassword = 'notasecret'

# Email parameters
$emailFrom='[email protected]'
$emailTo='[email protected]','[email protected]'
$subject='Hyper-V Hosts Capacity Report'
$smtpRelayServer='relay02.dragoncoin.com'
$emailDay='Monday'

function getAllHyperVClusters($domainObjects){
    function getHyperVHosts($domainObjects){
        function includeRSAT{
            $ErrorActionPreference='stop'
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            #$rsatWindows7x32='https://download.microsoft.com/download/4/F/7/4F71806A-1C56-4EF2-9B4F-9870C4CFD2EE/Windows6.1-KB958830-x86-RefreshPkg.msu'
            $rsatWindows7x64='https://download.microsoft.com/download/4/F/7/4F71806A-1C56-4EF2-9B4F-9870C4CFD2EE/Windows6.1-KB958830-x64-RefreshPkg.msu'
            $rsatWindows81='https://download.microsoft.com/download/1/8/E/18EA4843-C596-4542-9236-DE46F780806E/Windows8.1-KB2693643-x64.msu'
            $rsat1709 = "https://download.microsoft.com/download/1/D/8/1D8B5022-5477-4B9A-8104-6A71FF9D98AB/WindowsTH-RSAT_WS_1709-x64.msu"
            $rsat1803 = "https://download.microsoft.com/download/1/D/8/1D8B5022-5477-4B9A-8104-6A71FF9D98AB/WindowsTH-RSAT_WS_1803-x64.msu"
            $rsatWs2016 = "https://download.microsoft.com/download/1/D/8/1D8B5022-5477-4B9A-8104-6A71FF9D98AB/WindowsTH-RSAT_WS2016-x64.msu"
    
            # This command does not work on Windows 2012R2
            #$releaseId=(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name ReleaseId).ReleaseId
            #Get-ItemProperty : Property ReleaseId does not exist at path HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows
            #NT\CurrentVersion.
            #At line:1 char:2
            #+ (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Na ...
            #+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
            #    + CategoryInfo          : InvalidArgument: (ReleaseId:String) [Get-ItemProperty], PSArgumentException
            #    + FullyQualifiedErrorId : System.Management.Automation.PSArgumentException,Microsoft.PowerShell.Commands.GetItemPropertyCommand
    
            $releaseId=(Get-Item "HKLM:SOFTWARE\Microsoft\Windows NT\CurrentVersion").GetValue('ReleaseID')
            $osVersion=[System.Environment]::OSVersion.Version
            [double]$osVersionMajorMinor="$($osVersion.Major).$($osVersion.Minor)" 
            $osName=(Get-WmiObject Win32_OperatingSystem).Name
            #$osType=switch ((Get-CimInstance -ClassName Win32_OperatingSystem).ProductType){
            #    1 {'client'}
            #    2 {'domaincontroller'}
            #    3 {'memberserver'}
            #    }
    
            $windowsVersion=(Get-CimInstance Win32_OperatingSystem).Version
    
            switch ($releaseId){
                1607{write-host 'Windows Server 2016 Release 1607 detected';$link=$rsatWs2016;break}
                1709{write-host 'Windows Server 2016 Release 1709 detected';$link=$rsat1709;break}
                1803{write-host 'Windows Server 2016 Release 1803 detected';$link=$rsat1803}
            }
        
            switch ($osVersionMajorMinor){
                {$_ -eq 6.0}{write-host 'Windows Server 2008 or Windows Vista detected';$link=$rsat1709;break}
                {$_ -eq 6.1}{write-host 'Windows Server 2008 R2 or Windows 7 detected';$link=$rsatWindows7x64;break}
                {$_ -eq 6.2}{write-host 'Windows Server 2012 or Windows 8.1 detected';$link=$rsatWindows81;break}
                {$_ -eq 6.3}{write-host 'Windows Server 2012 R2 detected';$link=$rsatWindows81}
            }
    
            if (!(Get-Module -ListAvailable -Name ActiveDirectory -EA SilentlyContinue)){
                Write-host "Prerequisite checks: module ActiveDirectory NOT currently available on this system. Please wait while the program adds that plugin..."
                try{
                    # If OS is Windows Server, then install RSAT using a different method
                    if ($osName -match "^Microsoft Windows Server") {
                        # This sequence has confirmed to be valid on Windows Server 2008 R2 and above
                        Write-Verbose "Importing Windows Feature: RSAT-AD-PowerShell"
                        Import-Module ServerManager
                        Add-WindowsFeature RSAT-AD-PowerShell
                        }
                    else{
                        Write-Verbose "This sequence targets Windows Client versions"
                        $destinationFile= ($ENV:USERPROFILE) + "\Downloads\" + (split-path $link -leaf)
                        Write-Host "Downloading RSAT from $link..."
                        Start-BitsTransfer -Source $link -Destination $destinationFile
                        $fileCheck=Get-AuthenticodeSignature $destinationFile
                        if($fileCheck.status -ne "valid") {write-host "$destinationFile is not valid. Please try again...";break}
                        $wusaCommand = $destinationFile + " /quiet"
                        Write-host "Installing RSAT - please wait..."
                        Start-Process -FilePath "C:\Windows\System32\wusa.exe" -ArgumentList $wusaCommand -Wait
                        }
                    return $true
                    }
                catch{
                    write-warning "$($error[0].Exception)"
                    return $false
                    }
            }else{
                Write-host "Prerequisite checks: module ActiveDirectory IS currently available on this system." -ForegroundColor Green
                return $true
                }
        }
        function listAllHyperVNodes($domainObjects){
            try{
                #if(!$domains){$domains=(Get-ADForest).Name|%{(Get-ADForest -Identity $_).Name}}
                $allHyperVNodes=@()
                foreach ($domainObject in $domainObjects){
                    #[string]$dc=(get-addomaincontroller -DomainName "$domain" -Discover -NextClosestSite).HostName
                    $domain=$domainObject.domain
                    $username=$domainObject.username
                    $password=$domainObject.password
                    $encryptedPassword=ConvertTo-securestring $password -AsPlainText -Force
                    $credential=New-Object -TypeName System.Management.Automation.PSCredential -Args $username,$encryptedPassword
                    $dc=if(!($domainObject.dc)){
                        $domainNode=.{try{get-addomain -Server $domain}catch{$false}}
                        if (!$domainNode){return $null}
                        if($domainNode.ReplicaDirectoryServers[1]){
                                $domainNode.ReplicaDirectoryServers[1]
                            }else{
                                $domainNode.PDCEmulator
                            }
                    }else{
                        $domainObject.dc
                    }
                    $session=.{
                        try{
                            new-pssession -computername $dc -credential $credential -EA Stop
                            write-host "Connected to $dc..."
                        }catch{
                            write-warning $_
                            return $false
                        }
                    }
                    if($session.state -eq 'Opened'){
                        write-host "Collecting all Hyper-V Clusters in $domain. This may take a while, depending on cluster sizes."
                        $allClusters=.{              
                            $clusters=invoke-command -session $session -scriptblock{
                                $rsatClusteringPowershell=get-WindowsFeature RSAT-Clustering-PowerShell
                                if(!$rsatClusteringPowershell.Installed){Add-WindowsFeature RSAT-Clustering-PowerShell}
                                (get-cluster -domain $env:USERDNSDOMAIN).Name
                            }
                            return $clusters
                        }
                        Remove-PSSession $session
                        foreach ($cluster in $allClusters){
                            $clusterName="$cluster.$domain"
                            $retries=3
                            do{
                                $newSession=.{
                                    try{
                                        new-pssession -computername $clusterName -credential $credential -EA Stop
                                        write-host "Connected to $clusterName..."
                                    }catch{
                                        return $_
                                    }
                                }
                                $retries--
                            }until($newSession.state -eq 'Opened' -OR $retries -le 0)
                            if($newSession.state -eq 'Opened'){
                                write-host "Checking $clusterName"
                                try{
                                    $nodes=try{
                                            invoke-command -session $newSession -scriptblock{
                                            param($clustername)
                                            #$rsatClusteringPowershell=get-WindowsFeature RSAT-Clustering-PowerShell
                                            #if(!$rsatClusteringPowershell.Installed){Add-WindowsFeature RSAT-Clustering-PowerShell}
                                            $x=Get-ClusterNode -Cluster $clustername -ea SilentlyContinue
                                            if($x){
                                                $x|Where-Object{$_.State -eq 'Up'}|Select-Object Name,@{name='Cluster';e={"$clustername.$env:USERDNSDOMAIN"}}
                                            }else{
                                                $false
                                            }
                                        } -Args $cluster -EA Stop|select Name,Cluster
                                    }catch{
                                        write-warning $_
                                    }
                                    if($nodes){
                                        $allHyperVNodes+=$nodes
                                    }else{
                                        write-warning "$cluster is skipped as program is unable to gather node names"
                                    }
                                }catch{                                    
                                    write-warning $_
                                }
                                remove-pssession $newSession
                            }else{
                                write-warning $newSession
                                write-warning "$env:computername cannot connect to $clustername as required to gather Hyper-V node names."
                            }
                        }
                    }else{
                        write-warning "$env:computername cannot connect to $dc as required to gather domain objects data."
                    }
                }
                return $allHyperVNodes
            }catch{
                Write-Error $_
                return $false
                }
        }

        try{
            #$null=includeRSAT;
            #$rsatClusteringPowershell=get-WindowsFeature RSAT-Clustering-PowerShell
            #if(!$rsatClusteringPowershell.Installed){Add-WindowsFeature RSAT-Clustering-PowerShell}                                    
            $hyperVHosts=listAllHyperVNodes $domainObjects
            $hyperVHostNames=$hyperVHosts|sort -property Cluster
            return $hyperVHostNames
        }catch{
            Write-Error $_
            return $false
            }
    }
    function sortArrayStringAsNumbers([string[]]$names){
        $hashTable=@{}
        $maxLength=($names | Measure-Object -Maximum -Property Length).Maximum
        foreach ($name in $names){
            #[int]$x=.{[void]($name -match '(?:.(\d+))+$');$matches[1]}
            #$x=.{[void]($name -match '(?:.(\d+)+)$');@($name.substring(0,$name.length-$matches[1].length),$matches[1])}
            $originalName=$name
            $x=.{
                [void]($name -match '(?:.(\d+)+)\w{0,}$');
                if($matches){
                    [int]$trailingNonDigits=([regex]::match($name,'\D+$').value).length
                    if($trailingNonDigits){
                        $name=$name.substring(0,$name.length-$trailingNonDigits)
                    }
                    return ($name.substring(0,$name.length-$matches[1].length))+$matches[1].PadLeft($maxLength,'0');
                }else{
                    return $name+''.PadLeft($maxLength,'0');
                }}
            $hashTable.Add($originalName,$x)
            }
        $sorted=foreach($item in $hashTable.GetEnumerator() | Sort Value){$item.Name}
        return $sorted
    }

    #write-host "Obtaining cluster names and associated hosts..."
    $hyperVHostsInForest=getHyperVHosts $domainObjects
    $sortedArray=@()
    $clusters=$hyperVHostsInForest|Group-Object -Property Cluster
    foreach($cluster in $clusters){
        $clusterName=$cluster.Name
        write-host $clusterName       
        $sortedHostnames=sortArrayStringAsNumbers $cluster.Group.Name
        $sortedHostnames|%{$sortedArray+=New-Object -TypeName psobject -Property @{hostname=$_; cluster=$clusterName}}
    }
    return $sortedArray
}
function getQuickStats($computername=$env:computername){    
    $ErrorActionPreference='stop'
    try{
        # write-host "Checking $env:computername`:"
        # Server Model
        $biosRegKey="REGISTRY::HKEY_LOCAL_MACHINE\Hardware\Description\System\Bios"
        $bios=Get-ItemProperty $biosRegKey
        $model=$bios.SystemProductName

        # RAM (IN GB)
        write-host " system memory..." -NoNewline
        $ramInfo=get-wmiobject win32_physicalmemory|Select-Object *
        $ramInfo=get-CimInstance win32_physicalmemory|Select-Object *
        $ramGb=.{$sum=0;$ramInfo.Capacity|%{ $sum += $_};return $sum/1GB}

        # RAM Module size
        #$ramModuleSize=($ramInfo.Capacity|select -unique)|%{$_/1GB}
        $ramGroups=$ramInfo.Capacity|Group|select Count,@{name='ramSize';e={($_.Group|select -Unique)/1GB}}
        $ramModuleSize=$ramGroups|%{"($($_.Count)) $($_.ramSize)"}

        # Ram Speed (Mhz)
        # $ramSpeed=$ramInfo.Speed|select -unique
        $ramSpeedGroups=$ramInfo.Speed|Group|select Count,@{name='ramSize';e={$_.Group|select -Unique}}
        $ramSpeed=$ramSpeedGroups|%{"($($_.Count)) $($_.ramSize)"}

        # ramConfiguredClockSpeed
        #$ramConfiguredSpeed=$ramInfo.ConfiguredClockSpeed|select -unique
        $ramConfigSpeedGroups=$ramInfo.ConfiguredClockSpeed|Group|select Count,@{name='ramSize';e={$_.Group|select -Unique}}
        $ramConfiguredSpeed=$ramConfigSpeedGroups|%{"($($_.Count)) $($_.ramSize)"}
        
        # RAM in use        
        $osObject=get-wmiobject Win32_OperatingSystem
        $os=$osObject.Caption
        $ramPercentFree=$osObject.FreePhysicalMemory/$osObject.TotalVisibleMemorySize
        $ramUsed=[math]::round($ramGb-($ramPercentFree*$ramGb),2)    

        # Slots filled
        $ramSlotsFilled=($ramInfo|?{$_.Capacity}).count

        # Slots available
        $ramSlotsAvailable=$ramInfo.count

        # Cores
        write-host " system CPU..."
        $cpu=Get-CimInstance Win32_processor
        $cores=.{$total=0;$cpu.NumberOfCores|%{$total+=$_};return $total}

        # Proc count
        $sockets=$cpu.count

        # Cpu Type
        $cpuType=$cpu.Name|select -unique

        # Clock speed
        $cpuClock=($cpu.MaxClockSpeed|Measure-Object -Average).Average
        $cpuBandwidth=$cpuClock*$cores
        $cpuUtilizationPercent=($cpu|Measure-Object -property LoadPercentage -Average).Average
        $cpuUtilization=($cpuUtilizationPercent/100)*$cpuBandwidth

        #$cpuUtilizationPercent=((Get-WmiObject -Class Win32_PerfFormattedData_PerfOS_Processor).PercentProcessorTime|measure-object -Average).Average  
        # $cpuUtilizationPercent=.{#$rawValues=wmic cpu get loadpercentage
        #     $rawValues=$cpu.LoadPercentage
        #     $selectNumbers=$rawValues|?{$_ -match '\d+'}
        #     ($selectNumbers|Measure-Object -Average).Average
        # }

        # $cpuUtilizationPercent=(get-counter -Counter "\Processor(*)\% Processor Time" -SampleInterval 1 -MaxSamples 2 |`
        #     select -ExpandProperty countersamples | select -ExpandProperty cookedvalue | Measure-Object -Average).average
        
        # $cpuUtilization=((Get-process|Select-Object CPU).CPU|Measure-Object -sum).Sum
        # $cpuUtilizationPercent=($cpuUtilization/$cpuBandwidth)*100

        # $cpuProperties=@(
        #     @{Name="processName"; Expression = {$_.name}},
        #     @{Name="cpuPercent"; Expression = {$_.PercentProcessorTime}},    
        #     @{Name="MemoryGb"; Expression = {[Math]::Round(($_.workingSetPrivate / 1GB),2)}}
        # )
        # $totalCpuUsage=(Get-WmiObject -class Win32_PerfFormattedData_PerfProc_Process | 
        #     Select-Object $cpuProperties)|?{$_.'processName' -eq '_Total'}

        # Other methods not being used
        #$cs = Get-WmiObject -class Win32_ComputerSystem
        #$Sockets=$cs.numberofprocessors
        #$Cores=$cs.numberoflogicalprocessors
        
        # write-host " Hyper-V Guest VMs..." -NoNewline
        # $guestVms = Get-VM|Where-Object {$_.State -eq 'Running'}
        # $vmsCount = $guestVms.count
    }catch{
        write-warning $_
        continue
    }  
    return [PSCustomObject][ordered]@{
        node=$computername        
        model=$model
        cpuType=$cpuType
        sockets=$sockets
        cores=$cores
        cpuBandwidth=$cpuBandwidth
        cpuUtilization=[math]::round($cpuUtilization,2)
        cpuUtilizationPercent=[math]::round($cpuUtilizationPercent,2)
        ramGb=$ramGb
        ramUsedGb=[math]::round($ramUsed,2)
        ramUsedPercent=[math]::round(($ramUsed/$ramGb)*100,2)
        ramModuleSizeGb=$ramModuleSize
        ramSlotsAvailable=$ramSlotsAvailable
        ramSlotsFilled=$ramSlotsFilled
        ramSpeed=$ramSpeed
        ramConfigSpeed=$ramConfiguredSpeed
        #vmsCount=$vmsCount
        os=$os
    }    
}
function checkSystem($computername=$env:computername,$credential,$retries=3){ 
    do{
        $session=try{
            if($credential){
                    New-PSSession $computername -credential $credential
            }else{
                    New-PSSession $computername
            }
        }catch{
            $retries--       
            write-warning "There's a problem connecting to $computername ... Retrying $retries more times..."            
            $null
        }        
    }while(!$session -or $retries -gt 0)
    if($session.State -eq 'Opened'){        
        $quickStats=invoke-command -session $session -scriptblock{
                param($getQuickStats)
                [scriptblock]::create($getQuickStats).invoke()
            } -Args ${function:getQuickStats}
        Remove-PSSession $session
    }else{
        Write-Warning "$computername is not Reachable from $env:computername via WinRM"
        $quickStats=$false        
    }
    if($quickStats){
        return $quickStats|Select-Object -Property * -ExcludeProperty PSComputerName,RunspaceId,PSShowComputerName
    }else{
        return [PSCustomObject]@{
            computerName = $computername+' Unreachable'
        }
    }
}
function generateEmailContent{
    param(
        $arrayObjects,
        $selectFields='node,model,cpuType,sockets,cores,cpuBandwidth,cpuUtilizationPercent,ramGb,ramUsedPercent',
        $reportName,
        $summary,
        $css="
        <style>
        .h1 {
            font-size: 18px;
            height: 40px;
            padding-top: 80px;
            margin: auto;
            text-align: center;
        }
        .h5 {
            font-size: 22px;
            text-align: center;
        }
        .th {text-align: center;}
        .table {
            padding:7px;
            border:#4e95f4 1px solid;
            background-color: white;
            margin-left: auto;
            margin-right: auto;
            width: 100%
            }
        .colgroup {}
        .th { background: #0046c3; color: #fff; padding: 5px 10px; }
        .td { font-size: 11px; padding: 5px 20px; color: #000;
            width: 1px;
            white-space: pre;
            }
        .tr { background: #b8d1f3;}
        .tr:nth-child(even) {
            background: #dae5f4;
            width: 1%;
            white-space: nowrap
        }
        .tr:nth-child(odd) {
            background: #b8d1f3;
            width: 1%;
            white-space: nowrap
        }
        </style>
        "
        )
    $filteredArray=invoke-expression "`$arrayObjects|select-object $selectFields"
    $report=$filteredArray|ConvertTo-Html -Fragment|Out-String
    $reportHtml=$report -replace '\<(?<item>\w+)\>', '<${item} class=''${item}''>'
    $emailContent='<html><head>'+$css+"</head><body><h1>$reportName</h1>`n<h5>$summary</h5>"+$reportHtml+'</body></html>'
    return $emailContent
}

function generateHtmlFragment($arrayObjects,$selectFields,$reportName,$summary){
    $filteredArray=invoke-expression "`$arrayObjects|select-object $selectFields"
    $htmlFragment=$filteredArray|ConvertTo-Html -Fragment|Out-String
    $customHtml=$htmlFragment -replace '\<(?<item>\w+)\>', '<${item} class=''${item}''>'
    $customHtmlFragment="<h1>$reportName</h1>`n<h5>$summary</h5>"+$customHtml
    return $customHtmlFragment    
}

function packageHtmlEmailContent{
    param(
        $htmlBody,
        $footer,
        $css="
        <style>
        .h1 {
            font-size: 18px;
            height: 40px;
            padding-top: 80px;
            margin: auto;
            text-align: center;
        }
        .h5 {
            font-size: 22px;
            text-align: center;
        }
        .th {text-align: center;}
        .table {
            padding:7px;
            border:#4e95f4 1px solid;
            background-color: white;
            margin-left: auto;
            margin-right: auto;
            width: 100%
            }
        .colgroup {}
        .th { background: #0046c3; color: #fff; padding: 5px 10px; }
        .td { font-size: 11px; padding: 5px 20px; color: #000;
            width: 1px;
            white-space: pre;
            }
        .tr { background: #b8d1f3;}
        .tr:nth-child(even) {
            background: #dae5f4;
            width: 1%;
            white-space: nowrap
        }
        .tr:nth-child(odd) {
            background: #b8d1f3;
            width: 1%;
            white-space: nowrap
        }
        </style>
        "
        )
    $emailContent='<html><head>'+$css+"</head><body>$htmlBody"+$footer+'</body></html>'
    return $emailContent         
}
function emailReport{
    param(
        $emailFrom,
        $emailTo,
        $subject,
        $emailContent,
        $smtpRelayServer
    )
    Send-MailMessage -From $emailFrom `
    -To $emailTo `
    -Subject $subject `
    -Body $emailContent `
    -BodyAsHtml `
    -SmtpServer $smtpRelayServer    
}

function updateGoogleSheets{
    param(
        $spreadsheetID,
        $sheetName=$env:USERDNSDOMAIN,
        $reportCsv,
        $certPath,
        $certPassword='notasecret',
        $iss,
        $scope="https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file"
    )
    # Set security protocol to TLS 1.2 to avoid TLS errors
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    Import-Module UMN-Google

    # obtain token if necessary
    $accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPassword -iss $iss
    # Create new sheet, if not already exists
    try{Add-GSheetSheet -accessToken $accessToken -sheetName $sheetName -spreadSheetID $spreadsheetID}catch{}

    # Upload CSV data to Google Sheets with Set-GSheetData
    $inputCsv = Import-Csv $reportCsv
    $header = $inputCsv[0].psobject.properties.name|select -first 26 # Google default 26 columns max
    $import = new-Object System.Collections.ArrayList
    $import.Add($header) | Out-Null
    $inputCsv | ForEach-Object {
    $row=@(foreach($label in $header){$_."$($label)"})
    $import.Add($row) | Out-Null
    }
    $import.Add(@()) | Out-Null
    $import.Add(@( $summary -split '\r?\n'|%{$_.Trim('<br>$')}|out-string )) | Out-Null
    $columnsCount=$header.count
    $columnLetter=if($columnsCount -le 25){
            [char](65+$columnsCount)
        }else{
            'Z'
        }
    $range="A1:$columnLetter$($import.Count)"
    try{
        Set-GSheetData -accessToken $accessToken `
            -rangeA1 $range `
            -sheetName $sheetName `
            -spreadSheetID $spreadsheetID `
            -values $import #-Debug -Verbose            
        return $true
    }catch{
        write-warning $_
        return $false
    }
}

$hyperVClusters=getAllHyperVClusters $domainObjects
$clusterGroups=$hyperVClusters|Group-Object -Property cluster
$emailFragments=''
foreach($group in $clusterGroups){
    $clusterName=$group.Name
    $domain=[regex]::matches($clusterName,'\.(.*)').captures.groups[1].value
    $username=($domainObjects|?{$_.domain.toupper() -eq $domain.toupper()}).username
    $password=($domainObjects|?{$_.domain.toupper() -eq $domain.toupper()}).password
    $encryptedPassword=ConvertTo-securestring $password -AsPlainText -Force
    $credential=New-Object -TypeName System.Management.Automation.PSCredential -Args $username,$encryptedPassword
    write-host $clusterName
    $timeStamp=(get-date).tostring()
    $results=@()
    $hyperVHosts=$group.Group.hostname
    $hostCount=$hyperVHosts.Count
    for ($i=0;$i -lt $hostCount;$i++){
        $hostname="$($hyperVHosts[$i]).$domain"
        write-host "Scanning $($i+1) of $hostCount`: $($hyperVHosts[$i])..."        
        $retries=3        
        do{
            $result=checkSystem $hostname $credential
            $retries--
            if(!$result){
                $nslookupTime=measure-command {nslookup $hostname}
                write-warning "It has taken $([math]::round($nslookupTime.totalseconds,2)) seconds for $env:computername to lookup $hostname"
            }
        }until ($result -or $retries -eq 0)
        if($result){$results+=$result}
    }
    $totalRam=.{$total=0;$results.ramGb|%{$total+=$_};$total}
    $usedRam=.{$total=0;$results.ramUsedGb|%{$total+=$_};$total}
    $usedRamPercent=[math]::round($usedRam/$totalRam*100,2)
    $availableRam=[math]::round($totalRam-$usedRam,2)
    $totalCpuBandwidth=.{$total=0;$results.cpuBandwidth|%{$total+=$_};$total}
    $usedCpuBandwidth=.{$total=0;$results.cpuUtilization|%{$total+=$_};$total}
    $usedCpuPercent=[math]::round($usedCpuBandwidth/$totalCpuBandwidth*100,2)
    $summary="Data Timestamp: $timeStamp<br>
Hosts Count: $($hyperVHosts.count)<br>
Total CPU Bandwidth: $([math]::round($totalCpuBandwidth/1000)) Ghz<br>
Used CPU Percent: $usedCpuPercent %<br>
Total RAM: $totalRam GB<br>
Used RAM: $usedRam GB<br>
Used RAM Percent: $usedRamPercent %<br>
Available RAM: $availableRam GB<br>"
    $reportName="Hyper-V Capacity Report for Cluster $clustername"
    $emailFragment=generateHtmlFragment $results $selectFields $reportName $summary
    $emailFragments+=$emailFragment
    $reportCsvPath=join-path $workingDirectory "$clustername.csv"
    $null=$results|export-csv $reportCsvPath -NoTypeInformation   
    updateGoogleSheets $spreadsheetID $clusterName $reportCsvPath $certPath $certPassword $iss $scope
    }

$today=(get-date).DayOfWeek
if ($today -eq $emailDay){
    $footer="<br>GoogleSheets URL: $spreadsheetUrl<br>"
    $emailContent=packageHtmlEmailContent $emailFragments $footer
    emailReport $emailFrom $emailTo $subject $emailContent $smtpRelayServer
}

Previous Version:

# hyperVCapacityReport.ps1
# Version: 0.1

# Report parameters
$workingDirectory='C:\scripts\hyperVReports'
$selectFields='node,model,os,cpuType,sockets,cores,ramGb,ramUsedPercent,vmsCount'
$domainObjects=@(
    @{domain='kimconnect.com';dc='dc2.kimconnect.com';username='testAdmin';password='password'}    
    )

# Update this Google Sheets
$spreadsheetID='abcdefghijklmnopqstvwzyz1234567890'
$spreadsheetUrl='https://docs.google.com/spreadsheets/d/'+$spreadsheetID

# Google API Authozation
$scope = "https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file"
$certPath = 'C:\scripts\googleSheets\googleApiCert12345.p12'
$iss = '[email protected]'
$certPassword = 'notasecret'

# Email parameters
$emailFrom='[email protected]'
$emailTo='[email protected]'
$subject='Hyper-V Hosts Capacity Report'
$smtpRelayServer='smtprelay.kimconnect.com'
$emailDay='Monday'

function getAllHyperVClusters($domainObjects){
    function getHyperVHosts($domainObjects){
        function includeRSAT{
            $ErrorActionPreference='stop'
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            #$rsatWindows7x32='https://download.microsoft.com/download/4/F/7/4F71806A-1C56-4EF2-9B4F-9870C4CFD2EE/Windows6.1-KB958830-x86-RefreshPkg.msu'
            $rsatWindows7x64='https://download.microsoft.com/download/4/F/7/4F71806A-1C56-4EF2-9B4F-9870C4CFD2EE/Windows6.1-KB958830-x64-RefreshPkg.msu'
            $rsatWindows81='https://download.microsoft.com/download/1/8/E/18EA4843-C596-4542-9236-DE46F780806E/Windows8.1-KB2693643-x64.msu'
            $rsat1709 = "https://download.microsoft.com/download/1/D/8/1D8B5022-5477-4B9A-8104-6A71FF9D98AB/WindowsTH-RSAT_WS_1709-x64.msu"
            $rsat1803 = "https://download.microsoft.com/download/1/D/8/1D8B5022-5477-4B9A-8104-6A71FF9D98AB/WindowsTH-RSAT_WS_1803-x64.msu"
            $rsatWs2016 = "https://download.microsoft.com/download/1/D/8/1D8B5022-5477-4B9A-8104-6A71FF9D98AB/WindowsTH-RSAT_WS2016-x64.msu"
    
            # This command does not work on Windows 2012R2
            #$releaseId=(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name ReleaseId).ReleaseId
            #Get-ItemProperty : Property ReleaseId does not exist at path HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows
            #NT\CurrentVersion.
            #At line:1 char:2
            #+ (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Na ...
            #+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
            #    + CategoryInfo          : InvalidArgument: (ReleaseId:String) [Get-ItemProperty], PSArgumentException
            #    + FullyQualifiedErrorId : System.Management.Automation.PSArgumentException,Microsoft.PowerShell.Commands.GetItemPropertyCommand
    
            $releaseId=(Get-Item "HKLM:SOFTWARE\Microsoft\Windows NT\CurrentVersion").GetValue('ReleaseID')
            $osVersion=[System.Environment]::OSVersion.Version
            [double]$osVersionMajorMinor="$($osVersion.Major).$($osVersion.Minor)" 
            $osName=(Get-WmiObject Win32_OperatingSystem).Name
            #$osType=switch ((Get-CimInstance -ClassName Win32_OperatingSystem).ProductType){
            #    1 {'client'}
            #    2 {'domaincontroller'}
            #    3 {'memberserver'}
            #    }
    
            $windowsVersion=(Get-CimInstance Win32_OperatingSystem).Version
    
            switch ($releaseId){
                1607{write-host 'Windows Server 2016 Release 1607 detected';$link=$rsatWs2016;break}
                1709{write-host 'Windows Server 2016 Release 1709 detected';$link=$rsat1709;break}
                1803{write-host 'Windows Server 2016 Release 1803 detected';$link=$rsat1803}
            }
        
            switch ($osVersionMajorMinor){
                {$_ -eq 6.0}{write-host 'Windows Server 2008 or Windows Vista detected';$link=$rsat1709;break}
                {$_ -eq 6.1}{write-host 'Windows Server 2008 R2 or Windows 7 detected';$link=$rsatWindows7x64;break}
                {$_ -eq 6.2}{write-host 'Windows Server 2012 or Windows 8.1 detected';$link=$rsatWindows81;break}
                {$_ -eq 6.3}{write-host 'Windows Server 2012 R2 detected';$link=$rsatWindows81}
            }
    
            if (!(Get-Module -ListAvailable -Name ActiveDirectory -EA SilentlyContinue)){
                Write-host "Prerequisite checks: module ActiveDirectory NOT currently available on this system. Please wait while the program adds that plugin..."
                try{
                    # If OS is Windows Server, then install RSAT using a different method
                    if ($osName -match "^Microsoft Windows Server") {
                        # This sequence has confirmed to be valid on Windows Server 2008 R2 and above
                        Write-Verbose "Importing Windows Feature: RSAT-AD-PowerShell"
                        Import-Module ServerManager
                        Add-WindowsFeature RSAT-AD-PowerShell
                        }
                    else{
                        Write-Verbose "This sequence targets Windows Client versions"
                        $destinationFile= ($ENV:USERPROFILE) + "\Downloads\" + (split-path $link -leaf)
                        Write-Host "Downloading RSAT from $link..."
                        Start-BitsTransfer -Source $link -Destination $destinationFile
                        $fileCheck=Get-AuthenticodeSignature $destinationFile
                        if($fileCheck.status -ne "valid") {write-host "$destinationFile is not valid. Please try again...";break}
                        $wusaCommand = $destinationFile + " /quiet"
                        Write-host "Installing RSAT - please wait..."
                        Start-Process -FilePath "C:\Windows\System32\wusa.exe" -ArgumentList $wusaCommand -Wait
                        }
                    return $true
                    }
                catch{
                    write-warning "$($error[0].Exception)"
                    return $false
                    }
            }else{
                Write-host "Prerequisite checks: module ActiveDirectory IS currently available on this system." -ForegroundColor Green
                return $true
                }
        }
        function listAllHyperVNodes($domainObjects){
            try{
                #if(!$domains){$domains=(Get-ADForest).Name|%{(Get-ADForest -Identity $_).Name}}
                $allHyperVNodes=@()
                foreach ($domainObject in $domainObjects){
                    #[string]$dc=(get-addomaincontroller -DomainName "$domain" -Discover -NextClosestSite).HostName
                    $domain=$domainObject.domain
                    $username=$domainObject.username
                    $password=$domainObject.password
                    $encryptedPassword=ConvertTo-securestring $password -AsPlainText -Force
                    $credential=New-Object -TypeName System.Management.Automation.PSCredential -Args $username,$encryptedPassword
                    $dc=if(!($domainObject.dc)){
                        $domainNode=.{try{get-addomain -Server $domain}catch{$false}}
                        if (!$domainNode){return $null}
                        if($domainNode.ReplicaDirectoryServers[1]){
                                $domainNode.ReplicaDirectoryServers[1]
                            }else{
                                $domainNode.PDCEmulator
                            }
                    }else{
                        $domainObject.dc
                    }
                    $session=.{
                        try{
                            new-pssession -computername $dc -credential $credential -EA Stop
                            write-host "Connected to $dc..."
                        }catch{
                            write-warning $_
                            return $false
                        }
                    }
                    if($session){
                        write-host "Collecting all Hyper-V Clusters in $domain. This may take a while, depending on cluster sizes."
                        $allClusters=.{              
                            $clusters=invoke-command -session $session -scriptblock{
                                $rsatClusteringPowershell=get-WindowsFeature RSAT-Clustering-PowerShell
                                if(!$rsatClusteringPowershell.Installed){Add-WindowsFeature RSAT-Clustering-PowerShell}
                                (get-cluster -domain $env:USERDNSDOMAIN).Name
                            }
                            return $clusters
                        }                
                        foreach ($cluster in $allClusters){
                            write-host "Checking $cluster"
                            try{
                                $nodes=invoke-command -computername "$cluster.$domain" -credential $credential -scriptblock{
                                    param($clustername)
                                    #$rsatClusteringPowershell=get-WindowsFeature RSAT-Clustering-PowerShell
                                    #if(!$rsatClusteringPowershell.Installed){Add-WindowsFeature RSAT-Clustering-PowerShell}
                                    $x=Get-ClusterNode -Cluster $clustername -ea SilentlyContinue
                                    if($x){
                                        $x|Where-Object{$_.State -eq 'Up'}|Select-Object Name,@{name='Cluster';e={"$clustername.$env:USERDNSDOMAIN"}}
                                    }else{
                                        $false
                                    }
                                } -Args $cluster -EA Stop|select Name,Cluster
                                if($nodes){$allHyperVNodes+=$nodes} 
                            }catch{
                                write-warning "$cluster is skipped..."
                            }                   
                        }
                        Remove-PSSession $session
                    }else{
                        write-warning "$env:computername cannot connect to $dc..."
                    }
                }
                return $allHyperVNodes
            }catch{
                Write-Error $_
                return $false
                }
        }

        try{
            #$null=includeRSAT;
            #$rsatClusteringPowershell=get-WindowsFeature RSAT-Clustering-PowerShell
            #if(!$rsatClusteringPowershell.Installed){Add-WindowsFeature RSAT-Clustering-PowerShell}                                    
            $hyperVHosts=listAllHyperVNodes $domainObjects
            $hyperVHostNames=$hyperVHosts|sort -property Cluster
            return $hyperVHostNames
        }catch{
            Write-Error $_
            return $false
            }
    }
    function sortArrayStringAsNumbers([string[]]$names){
        $hashTable=@{}
        $maxLength=($names | Measure-Object -Maximum -Property Length).Maximum
        foreach ($name in $names){
            #[int]$x=.{[void]($name -match '(?:.(\d+))+$');$matches[1]}
            #$x=.{[void]($name -match '(?:.(\d+)+)$');@($name.substring(0,$name.length-$matches[1].length),$matches[1])}
            $originalName=$name
            $x=.{
                [void]($name -match '(?:.(\d+)+)\w{0,}$');
                if($matches){
                    [int]$trailingNonDigits=([regex]::match($name,'\D+$').value).length
                    if($trailingNonDigits){
                        $name=$name.substring(0,$name.length-$trailingNonDigits)
                    }
                    return ($name.substring(0,$name.length-$matches[1].length))+$matches[1].PadLeft($maxLength,'0');
                }else{
                    return $name+''.PadLeft($maxLength,'0');
                }}
            $hashTable.Add($originalName,$x)
            }
        $sorted=foreach($item in $hashTable.GetEnumerator() | Sort Value){$item.Name}
        return $sorted
    }

    #write-host "Obtaining cluster names and associated hosts..."
    $hyperVHostsInForest=getHyperVHosts $domainObjects
    $sortedArray=@()
    $clusters=$hyperVHostsInForest|Group-Object -Property Cluster
    foreach($cluster in $clusters){
        $clusterName=$cluster.Name
        write-host $clusterName       
        $sortedHostnames=sortArrayStringAsNumbers $cluster.Group.Name
        $sortedHostnames|%{$sortedArray+=New-Object -TypeName psobject -Property @{hostname=$_; cluster=$clusterName}}
    }
    return $sortedArray
}
function getQuickStats($computername=$env:computername){    
    try{
        # Server Model
        $biosRegKey="REGISTRY::HKEY_LOCAL_MACHINE\Hardware\Description\System\Bios"
        $bios=Get-ItemProperty $biosRegKey
        $model=$bios.SystemProductName

        # RAM (IN GB)
        $ramInfo=get-wmiobject win32_physicalmemory|Select-Object *
        $ramInfo=get-CimInstance win32_physicalmemory|Select-Object *
        $ramGb=.{$sum=0;$ramInfo.Capacity|%{ $sum += $_};return $sum/1GB}

        # RAM Module size
        #$ramModuleSize=($ramInfo.Capacity|select -unique)|%{$_/1GB}
        $ramGroups=$ramInfo.Capacity|Group|select Count,@{name='ramSize';e={($_.Group|select -Unique)/1GB}}
        $ramModuleSize=$ramGroups|%{"($($_.Count)) $($_.ramSize)"}

        # Ram Speed (Mhz)
        # $ramSpeed=$ramInfo.Speed|select -unique
        $ramSpeedGroups=$ramInfo.Speed|Group|select Count,@{name='ramSize';e={$_.Group|select -Unique}}
        $ramSpeed=$ramSpeedGroups|%{"($($_.Count)) $($_.ramSize)"}

        # ramConfiguredClockSpeed
        #$ramConfiguredSpeed=$ramInfo.ConfiguredClockSpeed|select -unique
        $ramConfigSpeedGroups=$ramInfo.ConfiguredClockSpeed|Group|select Count,@{name='ramSize';e={$_.Group|select -Unique}}
        $ramConfiguredSpeed=$ramConfigSpeedGroups|%{"($($_.Count)) $($_.ramSize)"}
        
        # RAM in use
        $osObject=get-wmiobject Win32_OperatingSystem
        $os=$osObject.Caption
        $ramPercentFree=$osObject.FreePhysicalMemory/$osObject.TotalVisibleMemorySize
        $ramUsed=[math]::round($ramGb-($ramPercentFree*$ramGb),2)    

        # Slots filled
        $ramSlotsFilled=($ramInfo|?{$_.Capacity}).count

        # Slots available
        $ramSlotsAvailable=$ramInfo.count
        # Cores
        $cpu=Get-CimInstance Win32_processor
        $cores=.{$total=0;$cpu.NumberOfCores|%{$total+=$_};return $total}

        # Proc count
        $sockets=$cpu.count

        # Cpu Type
        $cpuType=$cpu.Name|select -unique

        # Clock speed
        $cpuClock=($cpu.MaxClockSpeed|Measure-Object -Average).Average
        $cpuBandwidth=$cpuClock*$cores
        $cpuUtilizationPercent=($cpu|Measure-Object -property LoadPercentage -Average).Average
        $cpuUtilization=($cpuUtilizationPercent/100)*$cpuBandwidth

        #$cpuUtilizationPercent=((Get-WmiObject -Class Win32_PerfFormattedData_PerfOS_Processor).PercentProcessorTime|measure-object -Average).Average  
        # $cpuUtilizationPercent=.{#$rawValues=wmic cpu get loadpercentage
        #     $rawValues=$cpu.LoadPercentage
        #     $selectNumbers=$rawValues|?{$_ -match '\d+'}
        #     ($selectNumbers|Measure-Object -Average).Average
        # }

        # $cpuUtilizationPercent=(get-counter -Counter "\Processor(*)\% Processor Time" -SampleInterval 1 -MaxSamples 2 |`
        #     select -ExpandProperty countersamples | select -ExpandProperty cookedvalue | Measure-Object -Average).average
        
        # $cpuUtilization=((Get-process|Select-Object CPU).CPU|Measure-Object -sum).Sum
        # $cpuUtilizationPercent=($cpuUtilization/$cpuBandwidth)*100

        # $cpuProperties=@(
        #     @{Name="processName"; Expression = {$_.name}},
        #     @{Name="cpuPercent"; Expression = {$_.PercentProcessorTime}},    
        #     @{Name="MemoryGb"; Expression = {[Math]::Round(($_.workingSetPrivate / 1GB),2)}}
        # )
        # $totalCpuUsage=(Get-WmiObject -class Win32_PerfFormattedData_PerfProc_Process | 
        #     Select-Object $cpuProperties)|?{$_.'processName' -eq '_Total'}

        # Other methods not being used
        #$cs = Get-WmiObject -class Win32_ComputerSystem
        #$Sockets=$cs.numberofprocessors
        #$Cores=$cs.numberoflogicalprocessors

        $guestVms = Get-VM|Where-Object {$_.State -eq 'Running'}
        $vmsCount = $guestVms.count
    }catch{}  
    return [PSCustomObject][ordered]@{
        node=$computername        
        model=$model
        cpuType=$cpuType
        sockets=$sockets
        cores=$cores
        cpuBandwidth=$cpuBandwidth
        cpuUtilization=[math]::round($cpuUtilization,2)
        cpuUtilizationPercent=[math]::round($cpuUtilizationPercent,2)
        ramGb=$ramGb
        ramUsedGb=[math]::round($ramUsed,2)
        ramUsedPercent=[math]::round(($ramUsed/$ramGb)*100,2)
        ramModuleSizeGb=$ramModuleSize
        ramSlotsAvailable=$ramSlotsAvailable
        ramSlotsFilled=$ramSlotsFilled
        ramSpeed=$ramSpeed
        ramConfigSpeed=$ramConfiguredSpeed
        vmsCount=$vmsCount
        os=$os
    }    
}
function checkSystem($computername=$env:computername,$credential){ 
    $session=if($credential){
            New-PSSession $computername -credential $credential
        }else{
            New-PSSession $computername
        }
    if($session.State -eq 'Opened'){        
        $quickStats=invoke-command -session $session -scriptblock{
                param($getQuickStats)
                [scriptblock]::create($getQuickStats).invoke()
            } -Args ${function:getQuickStats}
        Remove-PSSession $session
    }else{
        Write-Warning "$computername is not Reachable from $env:computername via WinRM"
        $quickStats=$false        
    }
    if($quickStats){
        return $quickStats|Select-Object -Property * -ExcludeProperty PSComputerName,RunspaceId,PSShowComputerName
    }else{
        return [PSCustomObject]@{
            computerName = $computername+' Unreachable'
        }
    }
}
function generateEmailContent{
    param(
        $arrayObjects,
        $selectFields='node,model,cpuType,sockets,cores,cpuBandwidth,cpuUtilizationPercent,ramGb,ramUsedPercent',
        $reportName,
        $summary,
        $css="
        <style>
        .h1 {
            font-size: 18px;
            height: 40px;
            padding-top: 80px;
            margin: auto;
            text-align: center;
        }
        .h5 {
            font-size: 22px;
            text-align: center;
        }
        .th {text-align: center;}
        .table {
            padding:7px;
            border:#4e95f4 1px solid;
            background-color: white;
            margin-left: auto;
            margin-right: auto;
            width: 100%
            }
        .colgroup {}
        .th { background: #0046c3; color: #fff; padding: 5px 10px; }
        .td { font-size: 11px; padding: 5px 20px; color: #000;
            width: 1px;
            white-space: pre;
            }
        .tr { background: #b8d1f3;}
        .tr:nth-child(even) {
            background: #dae5f4;
            width: 1%;
            white-space: nowrap
        }
        .tr:nth-child(odd) {
            background: #b8d1f3;
            width: 1%;
            white-space: nowrap
        }
        </style>
        "
        )
    $filteredArray=invoke-expression "`$arrayObjects|select-object $selectFields"
    $report=$filteredArray|ConvertTo-Html -Fragment|Out-String
    $reportHtml=$report -replace '\<(?<item>\w+)\>', '<${item} class=''${item}''>'
    $emailContent='<html><head>'+$css+"</head><body><h1>$reportName</h1>`n<h5>$summary</h5>"+$reportHtml+'</body></html>'
    return $emailContent
}

function generateHtmlFragment($arrayObjects,$selectFields,$reportName,$summary){
    $filteredArray=invoke-expression "`$arrayObjects|select-object $selectFields"
    $htmlFragment=$filteredArray|ConvertTo-Html -Fragment|Out-String
    $customHtml=$htmlFragment -replace '\<(?<item>\w+)\>', '<${item} class=''${item}''>'
    $customHtmlFragment="<h1>$reportName</h1>`n<h5>$summary</h5>"+$customHtml
    return $customHtmlFragment    
}

function packageHtmlEmailContent{
    param(
        $htmlBody,
        $footer,
        $css="
        <style>
        .h1 {
            font-size: 18px;
            height: 40px;
            padding-top: 80px;
            margin: auto;
            text-align: center;
        }
        .h5 {
            font-size: 22px;
            text-align: center;
        }
        .th {text-align: center;}
        .table {
            padding:7px;
            border:#4e95f4 1px solid;
            background-color: white;
            margin-left: auto;
            margin-right: auto;
            width: 100%
            }
        .colgroup {}
        .th { background: #0046c3; color: #fff; padding: 5px 10px; }
        .td { font-size: 11px; padding: 5px 20px; color: #000;
            width: 1px;
            white-space: pre;
            }
        .tr { background: #b8d1f3;}
        .tr:nth-child(even) {
            background: #dae5f4;
            width: 1%;
            white-space: nowrap
        }
        .tr:nth-child(odd) {
            background: #b8d1f3;
            width: 1%;
            white-space: nowrap
        }
        </style>
        "
        )
    $emailContent='<html><head>'+$css+"</head><body>$htmlBody"+$footer+'</body></html>'
    return $emailContent         
}
function emailReport{
    param(
        $emailFrom,
        $emailTo,
        $subject,
        $emailContent,
        $smtpRelayServer
    )
    Send-MailMessage -From $emailFrom `
    -To $emailTo `
    -Subject $subject `
    -Body $emailContent `
    -BodyAsHtml `
    -SmtpServer $smtpRelayServer    
}

function updateGoogleSheets{
    param(
        $spreadsheetID,
        $sheetName=$env:USERDNSDOMAIN,
        $reportCsv,
        $certPath,
        $certPassword='notasecret',
        $iss,
        $scope="https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file"
    )
    # Set security protocol to TLS 1.2 to avoid TLS errors
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    Import-Module UMN-Google

    # obtain token if necessary
    $accessToken = Get-GOAuthTokenService -scope $scope -certPath $certPath -certPswd $certPassword -iss $iss
    # Create new sheet, if not already exists
    try{Add-GSheetSheet -accessToken $accessToken -sheetName $sheetName -spreadSheetID $spreadsheetID}catch{}

    # Upload CSV data to Google Sheets with Set-GSheetData
    $inputCsv = Import-Csv $reportCsv
    $header = $inputCsv[0].psobject.properties.name|select -first 26 # Google default 26 columns max
    $import = new-Object System.Collections.ArrayList
    $import.Add($header) | Out-Null
    $inputCsv | ForEach-Object {
    $row=@(foreach($label in $header){$_."$($label)"})
    $import.Add($row) | Out-Null
    }
    $import.Add(@()) | Out-Null
    $import.Add(@( $summary -split '\r?\n'|%{$_.Trim('<br>$')}|out-string )) | Out-Null
    $columnsCount=$header.count
    $columnLetter=if($columnsCount -le 25){
            [char](65+$columnsCount)
        }else{
            'Z'
        }
    $range="A1:$columnLetter$($import.Count)"
    try{
        $null=Clear-GSheetSheet -spreadSheetID $spreadsheetID -SheetName $sheetName -accessToken $accessToken
        $null=Set-GSheetData -accessToken $accessToken `
            -rangeA1 $range `
            -sheetName $sheetName `
            -spreadSheetID $spreadsheetID `
            -values $import #-Debug -Verbose
        write-host "Google Sheet $sheetName range $range has been updated."          
        return $true
    }catch{
        write-warning $_
        return $false
    }
}

$hyperVClusters=getAllHyperVClusters $domainObjects
$clusterGroups=$hyperVClusters|Group-Object -Property cluster
$emailFragments=''
foreach($group in $clusterGroups){
    $clusterName=$group.Name
    $domain=[regex]::matches($clusterName,'\.(.*)').captures.groups[1].value
    $username=($domainObjects|?{$_.domain.toupper() -eq $domain.toupper()}).username
    $password=($domainObjects|?{$_.domain.toupper() -eq $domain.toupper()}).password
    $encryptedPassword=ConvertTo-securestring $password -AsPlainText -Force
    $credential=New-Object -TypeName System.Management.Automation.PSCredential -Args $username,$encryptedPassword
    write-host $clusterName
    $timeStamp=(get-date).tostring()
    $results=@()
    $hyperVHosts=$group.Group.hostname
    $hostCount=$hyperVHosts.Count
    for ($i=0;$i -lt $hostCount;$i++){
        write-host "Scanning $($i+1) of $hostCount`: $($hyperVHosts[$i])..."
        $results+=checkSystem "$($hyperVHosts[$i]).$domain" $credential
    }
    $totalRam=.{$total=0;$results.ramGb|%{$total+=$_};$total}
    $usedRam=.{$total=0;$results.ramUsedGb|%{$total+=$_};$total}
    $usedRamPercent=[math]::round($usedRam/$totalRam*100,2)
    $availableRam=[math]::round($totalRam-$usedRam,2)
    $totalCpuBandwidth=.{$total=0;$results.cpuBandwidth|%{$total+=$_};$total}
    $usedCpuBandwidth=.{$total=0;$results.cpuUtilization|%{$total+=$_};$total}
    $usedCpuPercent=[math]::round($usedCpuBandwidth/$totalCpuBandwidth*100,2)
    $summary="Data Timestamp: $timeStamp<br>
Hosts Count: $($hyperVHosts.count)<br>
Total CPU Bandwidth: $([math]::round($totalCpuBandwidth/1000)) Ghz<br>
Used CPU Percent: $usedCpuPercent %<br>
Total RAM: $totalRam GB<br>
Used RAM: $usedRam GB<br>
Used RAM Percent: $usedRamPercent %<br>
Available RAM: $availableRam GB<br>"
    $reportName="Hyper-V Capacity Report for Cluster $clustername"
    $emailFragment=generateHtmlFragment $results $selectFields $reportName $summary
    $emailFragments+=$emailFragment
    $reportCsvPath=join-path $workingDirectory "$clustername.csv"
    $null=$results|export-csv $reportCsvPath -NoTypeInformation   
    updateGoogleSheets $spreadsheetID $clusterName $reportCsvPath $certPath $certPassword $iss $scope
    }

$today=(get-date).DayOfWeek
if ($today -eq $emailDay){
    $footer="<br>GoogleSheets URL: $spreadsheetUrl<br>"
    $emailContent=packageHtmlEmailContent $emailFragments $footer
    emailReport $emailFrom $emailTo $subject $emailContent $smtpRelayServer
}