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
}