Public/Sync-VMTags.ps1
|
function Sync-VMTags { <# .SYNOPSIS Automatically applies tags to VMs based on their metadata and a tag profile. .DESCRIPTION The main workhorse of VM-AutoTagger. Connects to a vCenter Server, ESXi host, or Hyper-V host, enumerates virtual machines, collects metadata (OS, CPU, RAM, snapshots, tools status, network, datastore, age, power state), and applies tags according to a customizable YAML tag profile. Tags are organized into categories (OS-Family, CPU-Tier, Memory-Tier, etc.) and each VM receives one tag per category (or multiple for categories like Network and Datastore that support multiple cardinality). For VMware vSphere, tags are applied as native vSphere tags via PowerCLI. For Microsoft Hyper-V, tags are stored as a structured JSON block in the VM Notes field (since Hyper-V does not have a native tag system). Supports filtering by VM name (with wildcards), cluster, and datacenter. DryRun mode shows what would be applied without making changes. Force mode overwrites existing tags even when they already match. After each sync, a state file is saved for drift detection by Get-VMDrift. .PARAMETER Server The vCenter Server, ESXi host, or Hyper-V hostname/IP to connect to. .PARAMETER Credential PSCredential for authentication. If omitted, uses the current session or prompts for credentials. .PARAMETER ProfilePath Path to a YAML tag profile file. If not specified, the built-in default profile is used, which covers the 10 standard tag categories. .PARAMETER VMName One or more VM names to process. Supports wildcards (e.g., "Web*", "DB-??-Prod"). If omitted, all VMs in scope are processed. .PARAMETER Cluster Filter VMs to a specific vSphere cluster. .PARAMETER Datacenter Filter VMs to a specific vSphere datacenter. .PARAMETER DryRun Show what tags would be applied without actually making any changes. Useful for previewing the impact of a sync operation. .PARAMETER Force Overwrite existing tag assignments even if the current tag matches. By default, tags that already match are skipped for efficiency. .PARAMETER Hypervisor The hypervisor platform to connect to. Valid values: 'VMware', 'HyperV'. Default: 'VMware'. When set to 'HyperV', uses the Hyper-V PowerShell module and stores tags as JSON in VM Notes instead of native vSphere tags. .PARAMETER OutputPath Path to save an HTML summary report of the sync operation. .EXAMPLE Sync-VMTags -Server vcenter.contoso.com Connects to vCenter and applies default tag profile to all VMs. .EXAMPLE Sync-VMTags -Server vcenter.contoso.com -VMName "Web*" -DryRun -Verbose Shows what tags would be applied to VMs matching "Web*" without making changes. .EXAMPLE $cred = Get-Credential Sync-VMTags -Server vcenter.contoso.com -Credential $cred -ProfilePath .\Profiles\enterprise.yml -Cluster "Production" -OutputPath .\sync-report.html Syncs tags for the Production cluster using the enterprise profile and generates an HTML report. .EXAMPLE Sync-VMTags -Server hyperv01 -Hypervisor HyperV Connects to a Hyper-V host and applies tags stored as JSON in VM Notes. .EXAMPLE Sync-VMTags -Server localhost -Hypervisor HyperV -DryRun -Verbose Preview tag assignments on the local Hyper-V host without making changes. .NOTES Author: Larry Roberts Requires: VMware.PowerCLI module (for VMware) or Hyper-V module (for HyperV) Part of the VM-AutoTagger module. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Server, [Parameter()] [PSCredential]$Credential, [Parameter()] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string]$ProfilePath, [Parameter()] [string[]]$VMName, [Parameter()] [string]$Cluster, [Parameter()] [string]$Datacenter, [Parameter()] [switch]$DryRun, [Parameter()] [switch]$Force, [Parameter()] [string]$OutputPath, [Parameter()] [ValidateSet('VMware', 'HyperV')] [string]$Hypervisor = 'VMware' ) begin { $startTime = Get-Date $syncResults = [System.Collections.ArrayList]::new() $errors = [System.Collections.ArrayList]::new() $tagAssignmentCount = 0 $tagSkippedCount = 0 $tagCreatedCount = 0 $categoryCreatedCount = 0 $vmProcessedCount = 0 Write-Verbose "VM-AutoTagger Sync starting at $startTime" if ($DryRun) { Write-Host "[DRY RUN] No changes will be made to vCenter." -ForegroundColor Cyan } } process { # --- Step 1: Connect to hypervisor --- Write-Verbose "Connecting to $Hypervisor host: $Server" $viConnection = $null try { $viConnection = Connect-Hypervisor -Server $Server -Credential $Credential -Hypervisor $Hypervisor if ($Hypervisor -eq 'VMware') { Write-Verbose "Connected to $Server (Version: $($viConnection.Version))" } else { Write-Verbose "Connected to Hyper-V host: $Server (Type: $($viConnection.Type))" } } catch { Write-Error "Failed to connect to $Hypervisor host '$Server': $_" return } try { # --- Step 2: Get 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 { $getVMParams = @{ Server = $Server; ErrorAction = 'Stop' } if ($Cluster) { Write-Verbose "Filtering to cluster: $Cluster" $getVMParams['Location'] = Get-Cluster -Name $Cluster -Server $Server -ErrorAction Stop } elseif ($Datacenter) { Write-Verbose "Filtering to datacenter: $Datacenter" $getVMParams['Location'] = Get-Datacenter -Name $Datacenter -Server $Server -ErrorAction Stop } $allVMs = @(Get-VM @getVMParams) } # Apply VM name filter if specified if ($VMName) { $filteredVMs = [System.Collections.ArrayList]::new() foreach ($pattern in $VMName) { $matched = @($allVMs | Where-Object { $_.Name -like $pattern }) foreach ($vm in $matched) { if ($filteredVMs.Name -notcontains $vm.Name) { [void]$filteredVMs.Add($vm) } } } $allVMs = $filteredVMs.ToArray() } Write-Verbose "Found $($allVMs.Count) VM(s) to process." if ($allVMs.Count -eq 0) { Write-Warning "No VMs found matching the specified criteria." return } # --- Step 3: Load tag profile --- $tagProfile = $null if ($ProfilePath) { Write-Verbose "Loading tag profile from: $ProfilePath" $tagProfile = Read-TagProfile -Path $ProfilePath } else { Write-Verbose "Using default tag profile." $tagProfile = Get-DefaultTagProfile } Write-Verbose "Profile '$($tagProfile.Name)' loaded with $($tagProfile.Categories.Count) categories." # --- Step 4: Ensure tag categories exist (VMware only) --- $categoryMap = @{} if ($Hypervisor -eq 'VMware') { foreach ($catDef in $tagProfile.Categories) { if (-not $DryRun) { $cat = Initialize-TagCategory -Name $catDef.Name ` -Cardinality $catDef.Cardinality ` -Description $catDef.Description if ($cat) { $categoryMap[$catDef.Name] = $cat if ($cat.PSObject.Properties['Uid'] -and $cat.Uid) { Write-Verbose "Category '$($catDef.Name)' ready." } else { $categoryCreatedCount++ } } } else { Write-Host " [DRY RUN] Would ensure category: $($catDef.Name) ($($catDef.Cardinality))" -ForegroundColor DarkGray } } } else { # Hyper-V: categories are implicit in the JSON tag block -- no setup needed if ($DryRun) { foreach ($catDef in $tagProfile.Categories) { Write-Host " [DRY RUN] Would use category: $($catDef.Name) (stored in VM Notes)" -ForegroundColor DarkGray } } } # --- Step 5: Process each VM --- $syncState = [System.Collections.ArrayList]::new() $vmIndex = 0 foreach ($vm in $allVMs) { $vmIndex++ $percentComplete = [math]::Round(($vmIndex / $allVMs.Count) * 100, 0) Write-Progress -Activity "Syncing VM Tags" -Status "$($vm.Name) ($vmIndex of $($allVMs.Count))" -PercentComplete $percentComplete Write-Verbose "Processing VM: $($vm.Name) ($vmIndex/$($allVMs.Count))" # Collect metadata $metadata = $null try { if ($Hypervisor -eq 'HyperV') { $metadata = Get-HyperVMetadata -VM $vm -IncludeCreationDate } else { $metadata = Get-VMMetadata -VM $vm -IncludeCreationDate } } catch { $errMsg = "Failed to collect metadata for $($vm.Name): $_" Write-Warning $errMsg [void]$errors.Add([PSCustomObject]@{ VM = $vm.Name; Error = $errMsg }) continue } $vmTagResults = [ordered]@{} if ($Hypervisor -eq 'HyperV') { # --- Hyper-V: collect all tags, then write once to Notes --- $hvTagsForVM = @{} # Get current tags from Notes (for comparison) $currentHVTags = @{} if (-not $DryRun) { try { $currentHVTags = Get-HyperVNotesTags -VM $vm } catch { Write-Verbose "Could not retrieve current tags from Notes for $($vm.Name): $_" } } foreach ($catDef in $tagProfile.Categories) { $tagValues = @(Resolve-TagValue -VMMetadata $metadata -CategoryDefinition $catDef) if ($tagValues.Count -eq 0) { Write-Verbose " No tag resolved for $($catDef.Name) on $($vm.Name)" continue } foreach ($tagValue in $tagValues) { if ([string]::IsNullOrWhiteSpace($tagValue)) { continue } if ($DryRun) { Write-Host " [DRY RUN] $($vm.Name): $($catDef.Name) = $tagValue" -ForegroundColor Yellow $tagAssignmentCount++ $vmTagResults[$catDef.Name] = $tagValue continue } # Check if tag already matches $alreadyAssigned = $false if (-not $Force -and $currentHVTags.ContainsKey($catDef.Name) -and $currentHVTags[$catDef.Name] -eq $tagValue) { $alreadyAssigned = $true } if ($alreadyAssigned) { Write-Verbose " Tag '$($catDef.Name)/$tagValue' already set on $($vm.Name) -- skipping." $tagSkippedCount++ } else { $tagAssignmentCount++ } $hvTagsForVM[$catDef.Name] = $tagValue $vmTagResults[$catDef.Name] = $tagValue } } # Write all tags at once to the VM Notes field if (-not $DryRun -and $hvTagsForVM.Count -gt 0) { if ($PSCmdlet.ShouldProcess("$($vm.Name): Set $($hvTagsForVM.Count) tag(s) in VM Notes", "Set HyperV Notes Tags")) { try { Set-HyperVNotesTags -VM $vm -Tags $hvTagsForVM -Server $Server Write-Verbose " Wrote $($hvTagsForVM.Count) tags to VM Notes for $($vm.Name)" } catch { $errMsg = "Failed to write tags to VM Notes for $($vm.Name): $_" Write-Warning " $errMsg" [void]$errors.Add([PSCustomObject]@{ VM = $vm.Name; Error = $errMsg }) } } } } else { # --- VMware: use native vSphere tags --- # Get current tag assignments for this VM (for comparison) $currentAssignments = @() if (-not $DryRun) { try { $currentAssignments = @(Get-TagAssignment -Entity $vm -ErrorAction SilentlyContinue) } catch { Write-Verbose "Could not retrieve current tag assignments for $($vm.Name): $_" } } # Process each category foreach ($catDef in $tagProfile.Categories) { $tagValues = @(Resolve-TagValue -VMMetadata $metadata -CategoryDefinition $catDef) if ($tagValues.Count -eq 0) { Write-Verbose " No tag resolved for $($catDef.Name) on $($vm.Name)" continue } foreach ($tagValue in $tagValues) { if ([string]::IsNullOrWhiteSpace($tagValue)) { continue } if ($DryRun) { Write-Host " [DRY RUN] $($vm.Name): $($catDef.Name) = $tagValue" -ForegroundColor Yellow $tagAssignmentCount++ $vmTagResults[$catDef.Name] = $tagValue continue } # Check if tag already assigned $alreadyAssigned = $false if (-not $Force) { $existingForCategory = $currentAssignments | Where-Object { $_.Tag.Category.Name -eq $catDef.Name -and $_.Tag.Name -eq $tagValue } if ($existingForCategory) { $alreadyAssigned = $true } } if ($alreadyAssigned) { Write-Verbose " Tag '$($catDef.Name)/$tagValue' already assigned to $($vm.Name) -- skipping." $tagSkippedCount++ $vmTagResults[$catDef.Name] = $tagValue continue } # For Single cardinality, remove existing tags in this category first if ($catDef.Cardinality -eq 'Single') { $existingCatAssignments = @($currentAssignments | Where-Object { $_.Tag.Category.Name -eq $catDef.Name }) foreach ($existing in $existingCatAssignments) { if ($PSCmdlet.ShouldProcess("$($vm.Name): Remove tag $($existing.Tag.Category.Name)/$($existing.Tag.Name)", "Remove Tag Assignment")) { try { Remove-TagAssignment -TagAssignment $existing -Confirm:$false -ErrorAction Stop Write-Verbose " Removed existing tag: $($existing.Tag.Category.Name)/$($existing.Tag.Name)" } catch { Write-Warning " Failed to remove existing tag from $($vm.Name): $_" } } } } # Ensure the tag exists $category = $categoryMap[$catDef.Name] if (-not $category) { Write-Warning " Category '$($catDef.Name)' not available -- skipping." continue } $tag = Initialize-Tag -Name $tagValue -Category $category if (-not $tag) { Write-Warning " Could not create/find tag '$($catDef.Name)/$tagValue' -- skipping." continue } # Assign the tag if ($PSCmdlet.ShouldProcess("$($vm.Name): Assign tag $($catDef.Name)/$tagValue", "New Tag Assignment")) { try { New-TagAssignment -Tag $tag -Entity $vm -ErrorAction Stop | Out-Null Write-Verbose " Assigned tag: $($catDef.Name)/$tagValue -> $($vm.Name)" $tagAssignmentCount++ $vmTagResults[$catDef.Name] = $tagValue } catch { $errMsg = "Failed to assign tag '$($catDef.Name)/$tagValue' to $($vm.Name): $_" Write-Warning " $errMsg" [void]$errors.Add([PSCustomObject]@{ VM = $vm.Name; Error = $errMsg }) } } } } } $vmProcessedCount++ # Build result object for this VM [void]$syncResults.Add([PSCustomObject]@{ VMName = $vm.Name PowerState = $metadata.PowerState Tags = $vmTagResults TagCount = $vmTagResults.Count }) # Build sync state entry [void]$syncState.Add([PSCustomObject]@{ Name = $metadata.Name PowerState = $metadata.PowerState GuestFamily = $metadata.GuestFamily GuestFullName = $metadata.GuestFullName NumCPU = $metadata.NumCPU MemoryGB = $metadata.MemoryGB ToolsStatus = $metadata.ToolsStatus SnapshotCount = $metadata.SnapshotCount SnapshotRisk = $metadata.SnapshotRisk Networks = $metadata.Networks Datastores = $metadata.Datastores ProvisionedSpaceGB = $metadata.ProvisionedSpaceGB Cluster = $metadata.Cluster Datacenter = $metadata.Datacenter VMAge = $metadata.VMAge }) } Write-Progress -Activity "Syncing VM Tags" -Completed # --- Step 6: Save sync state --- if (-not $DryRun) { $stateFilePath = Join-Path -Path $script:StateDir -ChildPath 'last-sync.json' $stateData = [PSCustomObject]@{ SyncDate = (Get-Date).ToString('o') Server = $Server ProfileName = $tagProfile.Name VMCount = $vmProcessedCount VMs = $syncState } try { $stateData | ConvertTo-Json -Depth 10 | Set-Content -Path $stateFilePath -Encoding UTF8 -Force Write-Verbose "Sync state saved to: $stateFilePath" } catch { Write-Warning "Failed to save sync state: $_" } } # --- Step 7: Generate HTML report --- if ($OutputPath) { Write-Verbose "Generating HTML report: $OutputPath" $endTime = Get-Date $duration = $endTime - $startTime $htmlContent = Build-SyncReportHtml -Results $syncResults ` -Errors $errors ` -Server $Server ` -ProfileName $tagProfile.Name ` -StartTime $startTime ` -Duration $duration ` -TagAssignments $tagAssignmentCount ` -TagSkipped $tagSkippedCount ` -DryRun:$DryRun try { $reportDir = Split-Path -Path $OutputPath -Parent if ($reportDir -and -not (Test-Path $reportDir)) { New-Item -Path $reportDir -ItemType Directory -Force | Out-Null } $htmlContent | Set-Content -Path $OutputPath -Encoding UTF8 -Force Write-Host "Report saved to: $OutputPath" -ForegroundColor Green } catch { Write-Warning "Failed to save HTML report: $_" } } } finally { # Disconnect from hypervisor if ($viConnection) { try { Disconnect-Hypervisor -Server $Server -Hypervisor $Hypervisor Write-Verbose "Disconnected from $Server." } catch { Write-Verbose "Could not cleanly disconnect from $Server." } } } } end { $endTime = Get-Date $duration = $endTime - $startTime # Build summary object $summary = [PSCustomObject]@{ Server = $Server Profile = if ($tagProfile) { $tagProfile.Name } else { 'default' } DryRun = [bool]$DryRun VMsProcessed = $vmProcessedCount TagsAssigned = $tagAssignmentCount TagsSkipped = $tagSkippedCount TagsCreated = $tagCreatedCount CategoriesCreated = $categoryCreatedCount Errors = $errors.Count Duration = $duration StartTime = $startTime EndTime = $endTime Results = $syncResults } $summary.PSObject.TypeNames.Insert(0, 'VMAutoTagger.SyncSummary') # Display summary Write-Host "" Write-Host "=== VM-AutoTagger Sync Summary ===" -ForegroundColor Cyan Write-Host " Server: $Server" Write-Host " Profile: $($summary.Profile)" Write-Host " VMs Processed: $vmProcessedCount" Write-Host " Tags Assigned: $tagAssignmentCount" Write-Host " Tags Skipped: $tagSkippedCount" Write-Host " Errors: $($errors.Count)" Write-Host " Duration: $($duration.ToString('mm\:ss'))" if ($DryRun) { Write-Host " Mode: DRY RUN (no changes made)" -ForegroundColor Yellow } Write-Host "" return $summary } } function Build-SyncReportHtml { [CmdletBinding()] param( [array]$Results, [array]$Errors, [string]$Server, [string]$ProfileName, [datetime]$StartTime, [timespan]$Duration, [int]$TagAssignments, [int]$TagSkipped, [switch]$DryRun ) $sb = [System.Text.StringBuilder]::new() # Build error rows $errorSection = '' if ($Errors.Count -gt 0) { $errSb = [System.Text.StringBuilder]::new() foreach ($err in $Errors) { $enc = [System.Web.HttpUtility]::HtmlEncode($err.Error) [void]$errSb.AppendLine(('<tr><td>' + $err.VM + '</td><td class="text-warning">' + $enc + '</td></tr>')) } $errorSection = '<div class="card"><h2>Errors (' + $Errors.Count + ')</h2><table><tr><th>VM</th><th>Error</th></tr>' + $errSb.ToString() + '</table></div>' } # Build distribution cards $tagDistribution = @{} foreach ($result in $Results) { if ($result.Tags) { foreach ($key in $result.Tags.Keys) { if (-not $tagDistribution.ContainsKey($key)) { $tagDistribution[$key] = @{} } $val = $result.Tags[$key] if (-not $tagDistribution[$key].ContainsKey($val)) { $tagDistribution[$key][$val] = 0 } $tagDistribution[$key][$val]++ } } } $distSb = [System.Text.StringBuilder]::new() foreach ($category in ($tagDistribution.Keys | Sort-Object)) { $tags = $tagDistribution[$category] $total = ($tags.Values | Measure-Object -Sum).Sum [void]$distSb.Append(('<div class="card"><h3>' + $category + '</h3><div class="bar-chart">')) foreach ($entry in ($tags.GetEnumerator() | Sort-Object Value -Descending)) { $pct = [math]::Round(($entry.Value / $total) * 100, 1) [void]$distSb.Append(('<div class="bar-row"><span class="bar-label">' + $entry.Key + '</span><div class="bar-track"><div class="bar-fill" style="width: ' + $pct + '%"></div></div><span class="bar-value">' + $entry.Value + ' (' + $pct + '%)</span></div>')) } [void]$distSb.AppendLine('</div></div>') } # Build VM rows $vmSb = [System.Text.StringBuilder]::new() foreach ($r in $Results) { $tagList = '<span class="text-muted">None</span>' if ($r.Tags -and $r.Tags.Count -gt 0) { $tSb = [System.Text.StringBuilder]::new() foreach ($t in $r.Tags.GetEnumerator()) { [void]$tSb.Append(('<span class="tag">' + $t.Key + ': ' + $t.Value + '</span> ')) } $tagList = $tSb.ToString() } $stateClass = switch ($r.PowerState) { 'PoweredOn' { 'status-good' }; 'PoweredOff' { 'status-bad' }; default { 'status-warn' } } [void]$vmSb.AppendLine(('<tr><td>' + $r.VMName + '</td><td><span class="' + $stateClass + '">' + $r.PowerState + '</span></td><td>' + $r.TagCount + '</td><td>' + $tagList + '</td></tr>')) } $dryRunBanner = '' if ($DryRun) { $dryRunBanner = '<div class="banner warning">DRY RUN - No changes were made to vCenter</div>' } $tsStr = '{0:yyyy-MM-dd HH:mm:ss}' -f $StartTime $nowStr = '{0:yyyy-MM-dd HH:mm:ss}' -f (Get-Date) $durStr = $Duration.ToString('mm\:ss') $html = @' <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>VM-AutoTagger Sync Report</title> <style> *{margin:0;padding:0;box-sizing:border-box} body{font-family:'Segoe UI',Tahoma,Geneva,Verdana,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;letter-spacing:.05em} .card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem} .card h2,.card h3{color:#d29922;margin-bottom:1rem} 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} tr:hover{background:#1c2128} .tag{display:inline-block;background:#1c2128;border:1px solid #30363d;border-radius:4px;padding:2px 8px;margin:2px;font-size:.8rem} .banner{padding:1rem;border-radius:8px;margin-bottom:1.5rem;font-weight:600;text-align:center} .banner.warning{background:#2a1a0a;border:1px solid #d29922;color:#d29922} .status-good{color:#3fb950}.status-bad{color:#f85149}.status-warn{color:#d29922} .text-warning{color:#d29922}.text-muted{color:#484f58} .bar-chart{padding:.5rem 0} .bar-row{display:flex;align-items:center;margin-bottom:.5rem} .bar-label{width:160px;font-size:.85rem;color:#c9d1d9;flex-shrink:0} .bar-track{flex:1;height:20px;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:90px;font-size:.8rem;color:#8b949e;text-align:right;flex-shrink:0} .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-AutoTagger Sync Report</h1><div class="subtitle">Server: %%SERVER%% | Profile: %%PROFILE%% | %%TIMESTAMP%%</div></div> %%DRYBANNER%% <div class="stats-grid"> <div class="stat-card"><div class="value">%%VMCOUNT%%</div><div class="label">VMs Processed</div></div> <div class="stat-card"><div class="value">%%TAGASSIGN%%</div><div class="label">Tags Assigned</div></div> <div class="stat-card"><div class="value">%%TAGSKIP%%</div><div class="label">Tags Skipped</div></div> <div class="stat-card"><div class="value">%%ERRCOUNT%%</div><div class="label">Errors</div></div> <div class="stat-card"><div class="value">%%DURATION%%</div><div class="label">Duration</div></div> </div> <div class="card"><h2>Tag Distribution</h2></div> %%DISTCARDS%% %%ERRSECTION%% <div class="card"><h2>VM Details</h2> <table><tr><th>VM Name</th><th>Power State</th><th>Tags</th><th>Tag Assignments</th></tr> %%VMROWS%% </table></div> <div class="footer">Generated by VM-AutoTagger v1.0.0 | %%NOW%%</div> </body></html> '@ $html = $html.Replace('%%SERVER%%', $Server) $html = $html.Replace('%%PROFILE%%', $ProfileName) $html = $html.Replace('%%TIMESTAMP%%', $tsStr) $html = $html.Replace('%%DRYBANNER%%', $dryRunBanner) $html = $html.Replace('%%VMCOUNT%%', $Results.Count.ToString()) $html = $html.Replace('%%TAGASSIGN%%', $TagAssignments.ToString()) $html = $html.Replace('%%TAGSKIP%%', $TagSkipped.ToString()) $html = $html.Replace('%%ERRCOUNT%%', $Errors.Count.ToString()) $html = $html.Replace('%%DURATION%%', $durStr) $html = $html.Replace('%%DISTCARDS%%', $distSb.ToString()) $html = $html.Replace('%%ERRSECTION%%', $errorSection) $html = $html.Replace('%%VMROWS%%', $vmSb.ToString()) $html = $html.Replace('%%NOW%%', $nowStr) return $html } |