Changing Current PowerCli Output from HTML to CSV with Each DataStore on Its Own Tab

A while back a fellow E. E member wrote the PowerCli script below for me list all VM on each datastore from a specific array and saved the content to a HTML. How can the script be change to save the data to a CSV but list each datastore on its own tab.
#$server = "[b]vcenterIP/DNSname[/b]"
#$user = "[b]user[/b]"
#$pwd = "[b]pass"[/b] 
$outFile = "C:\DS1.html"

# This section is based on http://www.vsysad.com/2013/07/powercli-script-to-get-naa-and-datastore-name-of-all-storage-on-esx-server/
# Create new property
New-VIProperty -Name lunDatastoreName -ObjectType ScsiLun -Value {
	param($lun)

	$ds = $lun.VMHost.ExtensionData.Datastore | %{Get-View $_} | `
	Where {$_.Summary.Type -eq "VMFS" -and ($_.Info.Vmfs.Extent | where {$_.DiskName -eq $lun.CanonicalName})}
    if($ds){
		$ds.Name
		}
	} -Force -WarningAction SilentlyContinue | Out-Null
#####

Connect-VIServer $server -User $user -Password $pwd
cls
$allHosts = Get-VMHost | where {($_.ConnectionState -like "Connected")} | Sort

# Get all unique datastores sorted by canonical names
Write-Host "`n"
$allCanonical=@()
foreach ($esxHost in $allHosts) {
	Write-Host "Analyzing host $esxHost"
	$allCanonical +=  Get-ScsiLun -VmHost $esxHost | Where  {$_.CanonicalName -like "naa.624a937045240161f584f368000*"} | Select CanonicalName, lunDatastoreName
	}
$allCanonical = $allCanonical | Sort-Object -Property lunDatastoreName  | Get-Unique -AsString
Write-Host -ForegroundColor Green "All hosts analyzed.`n"

# get VMs per each datastore
$body = @()
foreach ($dataStore in $allCanonical) {
	if ($dataStore.lunDatastoreName ) {
		Write-Host "Collecting VMs from dataStore=" $dataStore.lunDatastoreName
		$VMs = Get-VM -Datastore $dataStore.lunDatastoreName | Sort-Object
		$preText = "<h2>"+$datastore.lunDatastoreName + "  -  " + $datastore.CanonicalName + "</h2>"
		if ($VMs) {
			$report = $VMs | Select Name, @{N="vCPU";E={($_).NumCpu}}, @{N="Memory (GB)";E={($_).MemoryGB}}
			$body += $report | ConvertTo-Html -PreContent $preText
			}
		else {
			$body += "" | ConvertTo-Html -PreContent ($preText + "<h3>There is no VMs on this datastore</h3>")
		 }
		}
	else {
		Write-Host "Canonical name" $datastore.CanonicalName "has no datastore name"
		}
	}
Write-Host -ForegroundColor Green "All datastores analyzed.`n"

# Create  HTML report
$Header = @"
<style>
TABLE {border-width: 1px;border-style: solid;border-color: black;border-collapse: collapse; font-family: arial, verdana, sans-serif}
TR:Hover TD {Background-Color: #C1D5F8;}
TH {border-width: 1px;padding: 3px;border-style: solid;border-color: black;background-color: #6495ED;}
TD {border-width: 1px;padding: 3px;border-style: solid;border-color: black;}
.odd  { background-color:#ffffff; }
.even { background-color:#dddddd; }
.redtext {color: red;}
H1,H2,H3,H4,H5,H6 {font-family: arial, verdana, sans-serif}
p  {
	font-size: 150%;
	font-family: arial, verdana, sans-serif
	}
</style>
<title>
VM report
</title>
"@

ConvertTo-Html -Head $Header -Body $body  | Out-File -FilePath $outFile
Write-Host "`nHTML report is saved to $outFile" 

#Disconnect-VIServer -Server $server -Force -Confirm:$false
Write-Host -ForegroundColor Green "Done"

Open in new window

LVL 20
compdigit44Asked:
Who is Participating?
I wear a lot of hats...

"The solutions and answers provided on Experts Exchange have been extremely helpful to me over the last few years. I wear a lot of hats - Developer, Database Administrator, Help Desk, etc., so I know a lot of things but not a lot about one thing. Experts Exchange gives me answers from people who do know a lot about one thing, in a easy to use platform." -Todd S.

 
Sebastian TalmonSystem Engineer Datacenter SolutionsCommented:
Could you please specify "own tab"?

CSV do not have such things as tabs / do you mean Excel Tabs?
0
 
compdigit44Author Commented:
yes I am referring to excel tabs , sorry for the confusion
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
Store this library function to a separate PS1 file (e,g, export-XLS.ps1):
# Export object from pipe to Excel sheet

<# Example use:
  (new-object PSObject -Property @{a = "A" ; b = 1.5 }),
  (new-object PSObject -Property @{a = "NA"; b = 2   }) |
    export-xls "C:\temp\ee\tst.xlsx"
#>

<# To Do:
  optional header format, like Interior.ColorIndex, Font.ColorIndex, ...
  optional table formatting (script block)
#>
<# Note: If $SheetName is provided, the workbook will be saved but not closed
   to speed up processing.
   Also make sure to use PowerShell 4 or later for better Automation performance
#>
function export-xls ([String] $xlsFile, [String] $sheetName)
{
begin {
  # Excel initialization stuff
  # use global Excel instance object, if already set
  if (!$global:excel)
  {
    $global:excel = New-Object -ComObject excel.application
  }
  $excel.visible=$true

  # Open existing workbook or create new
  if (Test-Path $xlsFile)
  {
    $wb = $excel.Workbooks.Open($xlsFile)
  } else {
  	$wb = $excel.Workbooks.Add()            # empty, unnamed workbook

    #   Delete all worksheets but one
    $excel.DisplayAlerts = $false
    for ($i = $wb.Worksheets.Count; $i -ge 2; --$i) {$wb.Worksheets.Item($i).Delete()}
    $excel.DisplayAlerts = $true
    $ws = $wb.Worksheets.Item(1)
    if ($sheetName) { $ws.Name = $sheetName }
    $wb.SaveAs($xlsFile)
  }
  if ($sheetName)
  {
    if (!$ws) { $ws = $wb.Sheets | ? { $_.Name -eq $sheetName } }
    if (!$ws) { ($ws = $wb.WorkSheets.Add()).Name = $sheetName  }
  } else {
    $ws = $wb.WorkSheets.Add()    # new unnamed
  }
	$props = $null
	$row = 1
}
process {
	if (!$props) {
	  $col = 1
		$props = $_ | gm -MemberType NoteProperty
		$props | % {
			$ws.Cells.Item($row, $col).Value = $_.Name
			$ws.Cells.Item($row, $col++).Font.Bold = $true
		}
		$row++
	}
	$col = 1
	foreach ($prop in $props) {
		$ws.Cells.Item($row, $col++).Value = $_.($prop.Name).toString()
	}
	$row++
}
end {
	[void] $ws.usedRange.EntireColumn.AutoFit()
	$wb.Save()
	if (!$sheetName) { $excel.Quit() }
}
}

Open in new window

That way you can import it anywhere you need it.
Your script then looks like this:
#$server = "[b]vcenterIP/DNSname[/b]"
#$user = "[b]user[/b]"
#$pwd = "[b]pass"[/b] 
$outFile = "C:\DS1.xlsx"

. C:\Scripts\export-XLS.ps1

# This section is based on http://www.vsysad.com/2013/07/powercli-script-to-get-naa-and-datastore-name-of-all-storage-on-esx-server/
# Create new property
New-VIProperty -Name lunDatastoreName -ObjectType ScsiLun -Value {
	param($lun)

	$ds = $lun.VMHost.ExtensionData.Datastore | %{Get-View $_} | `
	Where {$_.Summary.Type -eq "VMFS" -and ($_.Info.Vmfs.Extent | where {$_.DiskName -eq $lun.CanonicalName})}
    if($ds){
		$ds.Name
		}
	} -Force -WarningAction SilentlyContinue | Out-Null
#####

Connect-VIServer $server -User $user -Password $pwd
cls
$allHosts = Get-VMHost | where {($_.ConnectionState -like "Connected")} | Sort

# Get all unique datastores sorted by canonical names
Write-Host "`n"
$allCanonical=@()
foreach ($esxHost in $allHosts) {
	Write-Host "Analyzing host $esxHost"
	$allCanonical +=  Get-ScsiLun -VmHost $esxHost | Where  {$_.CanonicalName -like "naa.624a937045240161f584f368000*"} | Select CanonicalName, lunDatastoreName
	}
Write-Host -ForegroundColor Green "All hosts analyzed.`n"

# get VMs per each datastore
foreach ($dataStore in $allCanonical | Sort-Object lunDatastoreName -Desc | Get-Unique -AsString ) {
	if ($dataStore.lunDatastoreName ) {
		Write-Host "Collecting VMs from dataStore=$($dataStore.lunDatastoreName)"
    $sheetName = $dataStore.lunDatastoreName + "  -  " + $datastore.CanonicalName
    $VMData = Get-VM -Datastore $dataStore.lunDatastoreName |
                Sort-Object -Desc |
                Select Name, @{N="vCPU";E={($_).NumCpu}}, @{N="Memory (GB)";E={($_).MemoryGB}}
    if (!$VMData) { $VMData = [PSCustomObject] @{Name = 'There are no VMs on this datastore'} }
  }	else {
		Write-Host "Canonical name $($datastore.CanonicalName) has no datastore name"
  }
  $VMData | Export-XLS -sheet $sheetName
}
Write-Host "`nResults saved into $outFile" 

# optional: Close generated Excel workbook
# $excel.Quit()

#Disconnect-VIServer -Server $server -Force -Confirm:$false
Write-Host -ForegroundColor Green "Done"

Open in new window

Note that I've sorted everything in descending order, as the WorkSheets.Add method adds to the beginning of the workbook. Getting around that  properly requires some effort, so I decided to keep it this way for now ;-).
0
Ultimate Tool Kit for Technology Solution Provider

Broken down into practical pointers and step-by-step instructions, the IT Service Excellence Tool Kit delivers expert advice for technology solution providers. Get your free copy now.

 
compdigit44Author Commented:
Thanks when I run the main script after updating it with the proper location of Export-Xls.ps1 it runs but get the errors listed  below and does not creat ethe Excel file output

You cannot call a method on a null-valued expression.
At C:\VmwareScripts\export-XLS.ps1:46 char:18
+     if (!$ws) { ($ws = $wb.WorkSheets.Add()).Name = $sheetName  }
+                  ~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull

You cannot call a method on a null-valued expression.
At C:\VmwareScripts\export-XLS.ps1:58 char:4
+             $ws.Cells.Item($row, $col).Value = $_.Name
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull

You cannot call a method on a null-valued expression.
At C:\VmwareScripts\export-XLS.ps1:59 char:4
+             $ws.Cells.Item($row, $col++).Font.Bold = $true
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
Line 46 of the main code snippet is missing the file name:
 $VMData | Export-XLS $outFile -sheet $sheetName

Open in new window

0
 
compdigit44Author Commented:
Thank you for getting back to me....  Still getting errors as seen below...

Exception calling "SaveAs" with "1" argument(s): "Microsoft Excel cannot
access the file 'C:\AE733010'. There are several possible reasons:
 The file name or path does not exist.
 The file is being used by another program.
 The workbook you are trying to save has the same name as a currently open
workbook."
At C:\VmwareScripts\export-XLS.ps1:41 char:5
+     $wb.SaveAs($xlsFile)
+     ~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], MethodInvocationException
    + FullyQualifiedErrorId : ComMethodTargetInvocation

Exception setting "Value": "Cannot set the Value property for PSMemberInfo
object of type "System.Management.Automation.PSParameterizedProperty"."
At C:\VmwareScripts\export-XLS.ps1:58 char:4
+             $ws.Cells.Item($row, $col).Value = $_.Name
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], SetValueInvocationExceptio
   n
    + FullyQualifiedErrorId : ExceptionWhenSetting
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
I guess you still have the workbook open from another try. Make sure all Excel instances are closed (check with Task Manager).
The other error might be related or not. If not, replace .Value with .Value2. though both should work.
0
 
compdigit44Author Commented:
All Excel file were close and I did try change to .value2 as suggest. I get the same error but this time have tons of spreads that open that have the heading, memory, name , vCPU and nothing about datastores. Also when the script runs my workstation beeps like crazy
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
The only issue I could find has been with the length of the sheet names - 31 chars allowed, but the naa.* ID already occupies 36. We will have to skip the canonical name when renaming sheets.

This is an improved export-xls, which now appends new sheets (instead of adding them to the front). With this, we no longer have to sort in reversed order ;-).
# Export object from pipe to Excel sheet

<# Example use:
  (new-object PSObject -Property @{a = "A" ; b = 1.5 }),
  (new-object PSObject -Property @{a = "NA"; b = 2   }) |
    export-xls "C:\temp\ee\tst.xlsx"
#>

<# To Do:
  optional header format, like Interior.ColorIndex, Font.ColorIndex, ...
  optional table formatting (script block)
#>
<# Note: If $SheetName is provided, the workbook will be saved but not closed
   to speed up processing.
   Also make sure to use PowerShell 4 or later for better Automation performance
#>
function export-xls ([String] $xlsFile, [String] $sheetName)
{
begin {
  # Excel initialization stuff
  # use global Excel instance object, if already set
  if (!$global:excel)
  {
    $global:excel = New-Object -ComObject excel.application
  }
  $excel.visible=$true

  # Open existing workbook or create new
  if (Test-Path $xlsFile)
  {
    $wb = $excel.Workbooks.Open($xlsFile)
  } else {
  	$wb = $excel.Workbooks.Add()            # empty, unnamed workbook

    #   Delete all worksheets but one
    $excel.DisplayAlerts = $false
    for ($i = $wb.Worksheets.Count; $i -ge 2; --$i) {$wb.Worksheets.Item($i).Delete()}
    $excel.DisplayAlerts = $true
    $ws = $wb.Worksheets.Item(1)
    if ($sheetName) { $ws.Name = $sheetName }
    $wb.SaveAs($xlsFile)
  }
  # if sheet should be named in an existing workbook, search its name
  if ($sheetName -and !$ws) { $ws = $wb.Sheets | ? { $_.Name -eq $sheetName } }
  # append a new sheet if not found (and workbook is not new)
  if (!$ws)
  {
    $lastWS = $wb.WorkSheets.Item($wb.WorkSheets.Count)
    $ws = $wb.WorkSheets.Add($lastWS)
    $lastWS.Move($ws)
  }

  # name it if a name has been provided
  if ($sheetName) { $ws.Name = $sheetName }
	$props = $null
	$row = 1
}
process {
	if (!$props) {
	  $col = 1
		$props = $_ | gm -MemberType NoteProperty
		$props | % {
			$ws.Cells.Item($row, $col).Value = $_.Name
			$ws.Cells.Item($row, $col++).Font.Bold = $true
		}
		$row++
	}
	$col = 1
	foreach ($prop in $props) {
		$ws.Cells.Item($row, $col++).Value = $_.($prop.Name).toString()
	}
	$row++
}
end {
	[void] $ws.usedRange.EntireColumn.AutoFit()
	$wb.Save()
	if (!$sheetName) { $excel.Quit() }
}
}

Open in new window

and the script:
#$server = "[b]vcenterIP/DNSname[/b]"
#$user = "[b]user[/b]"
#$pwd = "[b]pass"[/b]
$outFile = "C:\DS1.xlsx"

. C:\Scripts\export-XLS.ps1

# This section is based on http://www.vsysad.com/2013/07/powercli-script-to-get-naa-and-datastore-name-of-all-storage-on-esx-server/
# Create new property
New-VIProperty -Name lunDatastoreName -ObjectType ScsiLun -Value {
	param($lun)

	$ds = $lun.VMHost.ExtensionData.Datastore | %{Get-View $_} | `
	Where {$_.Summary.Type -eq "VMFS" -and ($_.Info.Vmfs.Extent | where {$_.DiskName -eq $lun.CanonicalName})}
    if($ds){
		$ds.Name
		}
	} -Force -WarningAction SilentlyContinue | Out-Null
#####

Connect-VIServer $server -User $user -Password $pwd
cls
$allHosts = Get-VMHost | where {($_.ConnectionState -like "Connected")} | Sort

# Get all unique datastores sorted by canonical names
Write-Host "`n"
$allCanonical=@()
foreach ($esxHost in $allHosts) {
	Write-Host "Analyzing host $esxHost"
	$allCanonical +=  Get-ScsiLun -VmHost $esxHost | Where  {$_.CanonicalName -like "naa.*"} | Select CanonicalName, lunDatastoreName
}
Write-Host -ForegroundColor Green "All hosts analyzed.`n"

# get VMs per each datastore
foreach ($dataStore in $allCanonical | Sort-Object lunDatastoreName | Get-Unique -AsString ) {
	if ($dataStore.lunDatastoreName ) {
    $sheetName = $dataStore.lunDatastoreName # + "  -  " + $datastore.CanonicalName
		Write-Host "Collecting VMs from dataStore $($dataStore.lunDatastoreName) to sheet $sheetName"
    $VMData = Get-VM -Datastore $dataStore.lunDatastoreName |
                Sort-Object Name |
                Select Name, @{N="vCPU";E={($_).NumCpu}}, @{N="Memory (GB)";E={($_).MemoryGB}}
    if (!$VMData) { $VMData = [PSCustomObject] @{Name = 'There are no VMs on this datastore'} }
  }	else {
		Write-Host "Canonical name $($datastore.CanonicalName) has no datastore name"
  }
  $VMData | Export-XLS $outFile -sheet $sheetName
}
Write-Host "`nResults saved into $outFile"

# optional: Close generated Excel workbook
# $excel.Quit()

#Disconnect-VIServer -Server $server -Force -Confirm:$false
Write-Host -ForegroundColor Green "Done"

Open in new window

I've widened the canonical name match - you might re-set your more restrictive one.

Note that using PowerCLI and COM Automation together seems to be fragile. If you get a COM error, you should restart PowerCLI, make sure no Excel started by PowerShell is running in Task Manager, and start over with running the script.
It might also make a difference (on a 64bit OS) whether you run 64bit or 32bit PowerCLI.
I had all kind of strange error messages because of one or both of above "violated".
0
 
compdigit44Author Commented:
Unfortunately I got the same results.

m : You must specify an object for the Get-Member cmdlet.
t C:\export-XLS.ps1:61 char:17
         $props = $_ | gm -MemberType NoteProperty
                       ~~~~~~~~~~~~~~~~~~~~~~~~~~~
   + CategoryInfo          : CloseError: (:) [Get-Member], InvalidOperationEx
  ception
   + FullyQualifiedErrorId : NoObjectInGetMember,Microsoft.PowerShell.Command
  s.GetMemberCommand


Let try something different. How could the original script be changed to dump all VM's from and specific array to a CSV
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
That is a different error than before, and means that there is an empty ($null) object in the pipeline, which again is not possible with my code ...
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
With your last comment, you could either have a single CSV with datastore name and canonical name as another columns, or create one CSV per datastore.
0
 
compdigit44Author Commented:
How about one CSV that list all VM from an area regardless of datastore
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
What defines an "area"?
0
 
compdigit44Author Commented:
sorry typo. Should have been array
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
Sorry? The whole point of the script is to list VMs per datastore.
0
 
compdigit44Author Commented:
no per array type
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
i don't see any reference to "array type" anywhere in the code, so no clue what you are after.
But I guess just having the data store as an additional column would suffice?
0
 
compdigit44Author Commented:
I mean the naa.xxxxx identifier.. as an example
0
 
compdigit44Author Commented:
If you have to dump all VM's that were on a SAN of a certain type i.e: list all VM's on XIV or NetApp storage to a CSV file how would you do this?
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
I would use a filter as you did in your original code. I've moved that to a variable provided in the script header.
Then export everything you had already plus datastore name and canonical name into a single CSV.
$server    = 'vcenter'
$user      = 'user'
$pwd       = 'pass'
$outFile   = 'C:\DS1.xlsx'
$LUNFilter = 'naa.*'

# This section is based on http://www.vsysad.com/2013/07/powercli-script-to-get-naa-and-datastore-name-of-all-storage-on-esx-server/
# Create new property
New-VIProperty -Name lunDatastoreName -ObjectType ScsiLun -Value {
	param($lun)

	$ds = $lun.VMHost.ExtensionData.Datastore | %{Get-View $_} | `
	Where {$_.Summary.Type -eq "VMFS" -and ($_.Info.Vmfs.Extent | where {$_.DiskName -eq $lun.CanonicalName})}
    if($ds){
		$ds.Name
		}
	} -Force -WarningAction SilentlyContinue | Out-Null
#####

Connect-VIServer $server -User $user -Password $pwd
cls
$allHosts = Get-VMHost | where {($_.ConnectionState -like "Connected")} | Sort

# Get all unique datastores sorted by canonical names
Write-Host "`n"
$allCanonical=@()
foreach ($esxHost in $allHosts) {
	Write-Host "Analyzing host $esxHost"
	$allCanonical +=  Get-ScsiLun -VmHost $esxHost | Where  {$_.CanonicalName -like $LUNFilter} | Select CanonicalName, lunDatastoreName
}
Write-Host -ForegroundColor Green "All hosts analyzed.`n"

# get VMs per each datastore
foreach ($dataStore in $allCanonical | Sort-Object lunDatastoreName | Get-Unique -AsString ) {
	if ($dataStore.lunDatastoreName ) {
		Write-Host "Collecting VMs from dataStore $($dataStore.lunDatastoreName)"
    $VMData = Get-VM -Datastore $dataStore.lunDatastoreName |
                Sort-Object Name |
                Select Name,
                       @{n='vCPU'         ; e={$_.NumCpu   }},
                       @{n='Memory (GB)'  ; e={$_.MemoryGB }},
                       @{n='DataStore'    ; e={$dataStore.lunDatastoreName }},
                       @{n='CanonicalName'; e={$datastore.CanonicalName    }}
    if (!$VMData) { $VMData = [PSCustomObject] @{
                       Name          = 'There are no VMs on this datastore'
                       vCPU          = $null
                       'Memory (GB)' = $null
                       DataStore     = $null
                       CanonicalName = $null}
                  }
  }	else {
		Write-Host "Canonical name $($datastore.CanonicalName) has no datastore name"
  }
  $VMData | Export-CSV $outFile -Append
}
Write-Host "`nResults saved into $outFile"

#Disconnect-VIServer -Server $server -Force -Confirm:$false
Write-Host -ForegroundColor Green "Done"

Open in new window

0

Experts Exchange Solution brought to you by ConnectWise

Your issues matter to us.

Facing a tech roadblock? Get the help and guidance you need from experienced professionals who care. Ask your question anytime, anywhere, with no hassle.

Start your 7-day free trial
 
compdigit44Author Commented:
This is really good. What do we need to change to just dump all VMs of from the same array type into a csv file. We can drop the datastore and Eui names
0
 
QlemoBatchelor, Developer and EE Topic AdvisorCommented:
Use a more specific filter than $LUNFilter = 'naa.*'?
0
Question has a verified solution.

Are you are experiencing a similar issue? Get a personalized answer when you ask a related question.

Have a better answer? Share it in a comment.

All Courses

From novice to tech pro — start learning today.