Public/Get-VMTagReport.ps1
|
function Get-VMTagReport { <# .SYNOPSIS Generates an HTML dashboard showing tag distribution across VMs. .DESCRIPTION Connects to a vCenter Server, ESXi host, or Hyper-V host, retrieves all virtual machines and their tag assignments, and generates an interactive HTML report with tag distribution charts and detail tables. The report shows: - Summary statistics (total VMs, tagged VMs, untagged VMs, total tag assignments) - Per-category distribution with CSS-only horizontal bar charts - Percentage breakdowns for each tag value within a category - Detail tables listing which VMs have which tags - Untagged VM list for categories where VMs are missing assignments The HTML report uses a dark theme consistent with other portfolio modules (background: #0d1117, amber/gold accent: #d29922, card backgrounds: #161b22). .PARAMETER Server The vCenter Server, ESXi host, or Hyper-V hostname/IP to connect to. .PARAMETER Credential PSCredential for authentication. .PARAMETER OutputPath The file path where the HTML report will be saved. .PARAMETER Category One or more tag category names to include in the report. If omitted, all VM-AutoTagger managed categories are included. .EXAMPLE Get-VMTagReport -Server vcenter.contoso.com -OutputPath .\tag-report.html Generates a full tag distribution report for all VMs on the server. .EXAMPLE Get-VMTagReport -Server vcenter.contoso.com -OutputPath .\os-report.html -Category "OS-Family","OS-Version" Generates a report limited to OS-related tag categories. .NOTES Author: Larry Roberts Requires: VMware.PowerCLI module (for VMware) or Hyper-V module (for HyperV) Part of the VM-AutoTagger module. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Server, [Parameter()] [PSCredential]$Credential, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$OutputPath, [Parameter()] [string[]]$Category, [Parameter()] [ValidateSet('VMware', 'HyperV')] [string]$Hypervisor = 'VMware' ) begin { $startTime = Get-Date Write-Verbose "Generating VM Tag Report from $Server" } process { # --- Connect to hypervisor --- $viConnection = $null try { $viConnection = Connect-Hypervisor -Server $Server -Credential $Credential -Hypervisor $Hypervisor Write-Verbose "Connected to $Server" } catch { Write-Error "Failed to connect to $Hypervisor host '$Server': $_" return } try { # --- Get all VMs --- Write-Verbose "Retrieving virtual machines..." if ($Hypervisor -eq 'HyperV') { $getVMParams = @{ ErrorAction = 'Stop' } if ($Server -ne 'localhost' -and $Server -ne $env:COMPUTERNAME -and $Server -ne '.') { $getVMParams['ComputerName'] = $Server } $allVMs = @(Hyper-V\Get-VM @getVMParams) } else { $allVMs = @(Get-VM -Server $Server -ErrorAction Stop) } Write-Verbose "Found $($allVMs.Count) VMs." # --- Get all tag assignments --- Write-Verbose "Retrieving tag assignments..." $allAssignments = @() if ($Hypervisor -eq 'HyperV') { # Read tags from VM Notes for each VM foreach ($vm in $allVMs) { try { $hvTags = Get-HyperVNotesTags -VM $vm $powerState = switch ($vm.State) { 'Running' { 'PoweredOn' } 'Off' { 'PoweredOff' } 'Saved' { 'Suspended' } 'Paused' { 'Suspended' } default { 'PoweredOff' } } foreach ($key in $hvTags.Keys) { $allAssignments += [PSCustomObject]@{ VMName = $vm.Name PowerState = $powerState CategoryName = $key TagName = $hvTags[$key] } } } catch { Write-Verbose "Could not get tags for $($vm.Name): $_" } } } else { foreach ($vm in $allVMs) { try { $assignments = @(Get-TagAssignment -Entity $vm -ErrorAction SilentlyContinue) foreach ($a in $assignments) { $allAssignments += [PSCustomObject]@{ VMName = $vm.Name PowerState = [string]$vm.PowerState CategoryName = $a.Tag.Category.Name TagName = $a.Tag.Name } } } catch { Write-Verbose "Could not get tags for $($vm.Name): $_" } } } Write-Verbose "Retrieved $($allAssignments.Count) total tag assignments." # --- Filter to requested categories --- if ($Category) { $allAssignments = @($allAssignments | Where-Object { $_.CategoryName -in $Category }) Write-Verbose "Filtered to $($allAssignments.Count) assignments in requested categories." } # --- Build category distribution data --- $categories = @($allAssignments | Select-Object -Property CategoryName -Unique | ForEach-Object { $_.CategoryName } | Sort-Object) $categoryData = [System.Collections.ArrayList]::new() foreach ($catName in $categories) { $catAssignments = @($allAssignments | Where-Object { $_.CategoryName -eq $catName }) $tagGroups = @($catAssignments | Group-Object -Property TagName | Sort-Object Count -Descending) $totalInCategory = $catAssignments.Count $uniqueVMs = @($catAssignments | Select-Object -Property VMName -Unique).Count $tagDetails = [System.Collections.ArrayList]::new() foreach ($group in $tagGroups) { $pct = if ($totalInCategory -gt 0) { [math]::Round(($group.Count / $totalInCategory) * 100, 1) } else { 0 } [void]$tagDetails.Add([PSCustomObject]@{ TagName = $group.Name VMCount = $group.Count Percentage = $pct VMs = @($group.Group | ForEach-Object { $_.VMName } | Sort-Object) }) } # Find untagged VMs for this category $taggedVMNames = @($catAssignments | ForEach-Object { $_.VMName } | Select-Object -Unique) $untaggedVMs = @($allVMs | Where-Object { $_.Name -notin $taggedVMNames } | ForEach-Object { $_.Name } | Sort-Object) [void]$categoryData.Add([PSCustomObject]@{ CategoryName = $catName TotalTags = $totalInCategory UniqueVMs = $uniqueVMs UntaggedVMs = $untaggedVMs TagDetails = $tagDetails }) } # --- Compute summary stats --- $totalVMs = $allVMs.Count $taggedVMNames = @($allAssignments | ForEach-Object { $_.VMName } | Select-Object -Unique) $taggedVMCount = $taggedVMNames.Count $untaggedVMCount = $totalVMs - $taggedVMCount $totalAssignments = $allAssignments.Count # --- Generate HTML --- $html = Build-TagReportHtml -CategoryData $categoryData ` -TotalVMs $totalVMs ` -TaggedVMs $taggedVMCount ` -UntaggedVMs $untaggedVMCount ` -TotalAssignments $totalAssignments ` -Server $Server ` -StartTime $startTime ` -CategoryCount $categories.Count } finally { if ($viConnection) { try { Disconnect-Hypervisor -Server $Server -Hypervisor $Hypervisor } catch { } } } # --- Save report --- try { $reportDir = Split-Path -Path $OutputPath -Parent if ($reportDir -and -not (Test-Path $reportDir)) { New-Item -Path $reportDir -ItemType Directory -Force | Out-Null } $html | Set-Content -Path $OutputPath -Encoding UTF8 -Force Write-Host "Tag report saved to: $OutputPath" -ForegroundColor Green } catch { Write-Error "Failed to save report: $_" } # Return summary object return [PSCustomObject]@{ Server = $Server TotalVMs = $totalVMs TaggedVMs = $taggedVMCount UntaggedVMs = $untaggedVMCount TotalAssignments = $totalAssignments Categories = $categoryData ReportPath = $OutputPath } } } function Build-TagReportHtml { [CmdletBinding()] param( [array]$CategoryData, [int]$TotalVMs, [int]$TaggedVMs, [int]$UntaggedVMs, [int]$TotalAssignments, [string]$Server, [datetime]$StartTime, [int]$CategoryCount ) $catSb = [System.Text.StringBuilder]::new() foreach ($cat in $CategoryData) { [void]$catSb.Append(('<div class="card"><div class="card-header"><h2>' + $cat.CategoryName + '</h2><span class="card-meta">' + $cat.UniqueVMs + ' VMs tagged | ' + $cat.TagDetails.Count + ' unique tag(s)</span></div><div class="bar-chart">')) foreach ($td in $cat.TagDetails) { $bw = [math]::Max($td.Percentage, 1) [void]$catSb.Append(('<div class="bar-row"><span class="bar-label">' + $td.TagName + '</span><div class="bar-track"><div class="bar-fill" style="width:' + $bw + '%"></div></div><span class="bar-value">' + $td.VMCount + ' VMs (' + $td.Percentage + '%)</span></div>')) } [void]$catSb.Append('</div><table><tr><th>Tag</th><th>VM Count</th><th>Percentage</th><th>VMs</th></tr>') foreach ($td in $cat.TagDetails) { $chipSb = [System.Text.StringBuilder]::new() $vmSubset = if ($td.VMs.Count -le 10) { $td.VMs } else { @($td.VMs)[0..9] } foreach ($vmn in $vmSubset) { [void]$chipSb.Append(('<span class="vm-chip">' + $vmn + '</span> ')) } if ($td.VMs.Count -gt 10) { [void]$chipSb.Append(('<span class="text-muted">...and ' + ($td.VMs.Count - 10) + ' more</span>')) } [void]$catSb.Append(('<tr><td><span class="tag-badge">' + $td.TagName + '</span></td><td>' + $td.VMCount + '</td><td>' + $td.Percentage + '%</td><td>' + $chipSb.ToString() + '</td></tr>')) } [void]$catSb.Append('</table>') if ($cat.UntaggedVMs.Count -gt 0) { $utSb = [System.Text.StringBuilder]::new() $utSubset = if ($cat.UntaggedVMs.Count -le 5) { $cat.UntaggedVMs } else { @($cat.UntaggedVMs)[0..4] } foreach ($u in $utSubset) { [void]$utSb.Append(('<span class="vm-chip untagged">' + $u + '</span> ')) } if ($cat.UntaggedVMs.Count -gt 5) { [void]$utSb.Append(('<span class="text-muted">...and ' + ($cat.UntaggedVMs.Count - 5) + ' more</span>')) } [void]$catSb.Append(('<div class="untagged-section"><strong class="text-warning">' + $cat.UntaggedVMs.Count + ' untagged VM(s):</strong> ' + $utSb.ToString() + '</div>')) } [void]$catSb.AppendLine('</div>') } $tsStr = '{0:yyyy-MM-dd HH:mm:ss}' -f $StartTime $nowStr = '{0:yyyy-MM-dd HH:mm:ss}' -f (Get-Date) $html = @' <!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"> <title>VM Tag Distribution Report</title><style> *{margin:0;padding:0;box-sizing:border-box}body{font-family:'Segoe UI',Tahoma,sans-serif;background:#0d1117;color:#c9d1d9;padding:2rem;line-height:1.6} .header{background:linear-gradient(135deg,#1a1f2e 0%,#2a1a0a 100%);padding:2rem;border-radius:8px;margin-bottom:2rem;border:1px solid #30363d} .header h1{color:#d29922;font-size:1.8rem;margin-bottom:.5rem}.header .subtitle{color:#8b949e;font-size:.95rem} .stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem} .stat-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.2rem;text-align:center} .stat-card .value{font-size:2rem;font-weight:bold;color:#d29922}.stat-card .label{color:#8b949e;font-size:.85rem;text-transform:uppercase} .card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem} .card-header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:1rem;flex-wrap:wrap}.card-header h2{color:#d29922}.card-meta{color:#8b949e;font-size:.85rem} table{width:100%;border-collapse:collapse;margin-top:.5rem}th{background:#21262d;color:#d29922;padding:.75rem 1rem;text-align:left;font-weight:600;border-bottom:2px solid #30363d} td{padding:.6rem 1rem;border-bottom:1px solid #21262d;vertical-align:top}tr:hover{background:#1c2128} .tag-badge{display:inline-block;background:#2a1a0a;border:1px solid #d29922;color:#d29922;border-radius:4px;padding:2px 10px;font-size:.85rem} .vm-chip{display:inline-block;background:#1c2128;border:1px solid #30363d;border-radius:4px;padding:1px 6px;margin:2px;font-size:.75rem;color:#c9d1d9} .vm-chip.untagged{border-color:#f85149;color:#f85149}.untagged-section{margin-top:1rem;padding-top:.75rem;border-top:1px solid #21262d} .bar-chart{padding:.5rem 0;margin-bottom:1rem}.bar-row{display:flex;align-items:center;margin-bottom:.5rem} .bar-label{width:180px;font-size:.85rem;color:#c9d1d9;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bar-track{flex:1;height:22px;background:#21262d;border-radius:4px;overflow:hidden;margin:0 .75rem} .bar-fill{height:100%;background:linear-gradient(90deg,#d29922,#e8b84a);border-radius:4px;min-width:2px} .bar-value{width:120px;font-size:.8rem;color:#8b949e;text-align:right;flex-shrink:0} .text-warning{color:#d29922}.text-muted{color:#484f58} .footer{text-align:center;color:#484f58;font-size:.8rem;margin-top:2rem;padding-top:1rem;border-top:1px solid #21262d} </style></head><body> <div class="header"><h1>VM Tag Distribution Report</h1><div class="subtitle">Server: %%SERVER%% | Generated: %%TS%%</div></div> <div class="stats-grid"> <div class="stat-card"><div class="value">%%TOTAL%%</div><div class="label">Total VMs</div></div> <div class="stat-card"><div class="value">%%TAGGED%%</div><div class="label">Tagged VMs</div></div> <div class="stat-card"><div class="value">%%UNTAGGED%%</div><div class="label">Untagged VMs</div></div> <div class="stat-card"><div class="value">%%ASSIGNS%%</div><div class="label">Tag Assignments</div></div> <div class="stat-card"><div class="value">%%CATCOUNT%%</div><div class="label">Categories</div></div> </div>%%CATCARDS%% <div class="footer">Generated by VM-AutoTagger v1.0.0 | %%NOW%%</div> </body></html> '@ $html = $html.Replace('%%SERVER%%', $Server).Replace('%%TS%%', $tsStr).Replace('%%TOTAL%%', $TotalVMs.ToString()) $html = $html.Replace('%%TAGGED%%', $TaggedVMs.ToString()).Replace('%%UNTAGGED%%', $UntaggedVMs.ToString()) $html = $html.Replace('%%ASSIGNS%%', $TotalAssignments.ToString()).Replace('%%CATCOUNT%%', $CategoryCount.ToString()) $html = $html.Replace('%%CATCARDS%%', $catSb.ToString()).Replace('%%NOW%%', $nowStr) return $html } |