Public/Get-StaleVMs.ps1

function Get-StaleVMs {
    <#
    .SYNOPSIS
        Identifies potentially stale or abandoned VMs based on power state and activity.
    .DESCRIPTION
        Connects to a vCenter Server or ESXi host and evaluates virtual machines
        for staleness indicators:

        - VMs powered off for longer than a configurable threshold (default: 30 days)
        - VMs powered on but showing near-zero activity (very low CPU/RAM allocation,
          or no VMware Tools heartbeat)
        - VMs with no network connectivity that appear idle

        For each stale VM, the function estimates monthly waste cost based on
        provisioned CPU and RAM, and generates a remediation recommendation.

        Results can be exported to an HTML report with a dark theme dashboard
        showing stale VM details, waste estimates, and recommended actions.
    .PARAMETER Server
        The vCenter Server, ESXi host, or Hyper-V hostname/IP to connect to.
    .PARAMETER Credential
        PSCredential for authentication.
    .PARAMETER VMName
        One or more VM names to check. Supports wildcards. If omitted, all VMs are evaluated.
    .PARAMETER Cluster
        Filter VMs to a specific vSphere cluster.
    .PARAMETER StaleThresholdDays
        Number of days a VM must be powered off to be considered stale. Default: 30.
    .PARAMETER CpuCostPerHour
        Cost per vCPU per hour for waste estimation. Default: $0.05.
    .PARAMETER RamCostPerGBHour
        Cost per GB RAM per hour for waste estimation. Default: $0.01.
    .PARAMETER OutputPath
        Path to save an HTML report of stale VM findings.
    .EXAMPLE
        Get-StaleVMs -Server vcenter.contoso.com

        Checks all VMs using default 30-day threshold.
    .EXAMPLE
        Get-StaleVMs -Server vcenter.contoso.com -StaleThresholdDays 14 -Cluster "Production" -OutputPath .\stale-report.html

        Checks Production cluster VMs with a 14-day threshold and generates an HTML report.
    .EXAMPLE
        $stale = Get-StaleVMs -Server vcenter.contoso.com
        $stale | Where-Object { $_.EstimatedWastePerMonth -gt 50 } | Format-Table VMName, DaysSincePowerOn, EstimatedWastePerMonth

        Finds VMs with estimated monthly waste over $50.
    .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()]
        [string[]]$VMName,

        [Parameter()]
        [string]$Cluster,

        [Parameter()]
        [ValidateRange(1, 3650)]
        [int]$StaleThresholdDays = 30,

        [Parameter()]
        [double]$CpuCostPerHour = 0.05,

        [Parameter()]
        [double]$RamCostPerGBHour = 0.01,

        [Parameter()]
        [string]$OutputPath,

        [Parameter()]
        [ValidateSet('VMware', 'HyperV')]
        [string]$Hypervisor = 'VMware'
    )

    begin {
        $startTime = Get-Date
        $staleVMs = [System.Collections.ArrayList]::new()
        Write-Verbose "Stale VM scan starting at $startTime (threshold: $StaleThresholdDays days)"
    }

    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 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) {
                    $getVMParams['Location'] = Get-Cluster -Name $Cluster -Server $Server -ErrorAction Stop
                }
                $allVMs = @(Get-VM @getVMParams)
            }

            # Apply VM name filter
            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 "Evaluating $($allVMs.Count) VMs for staleness..."

            # --- Evaluate each VM ---
            $vmIndex = 0
            foreach ($vm in $allVMs) {
                $vmIndex++
                Write-Progress -Activity "Scanning for Stale VMs" -Status "$($vm.Name) ($vmIndex of $($allVMs.Count))" -PercentComplete ([math]::Round(($vmIndex / $allVMs.Count) * 100))

                $metadata = $null
                try {
                    if ($Hypervisor -eq 'HyperV') {
                        $metadata = Get-HyperVMetadata -VM $vm -IncludeCreationDate
                    }
                    else {
                        $metadata = Get-VMMetadata -VM $vm -IncludeCreationDate
                    }
                }
                catch {
                    Write-Warning "Could not get metadata for $($vm.Name): $_"
                    continue
                }

                $isStale = $false
                $staleReason = ''
                $daysSincePowerOn = $null

                # --- Check 1: Powered-off VMs ---
                if ($metadata.PowerState -eq 'PoweredOff') {
                    if ($null -ne $metadata.LastPoweredOn) {
                        $daysSincePowerOn = [math]::Round(((Get-Date) - $metadata.LastPoweredOn).TotalDays, 0)
                    }
                    elseif ($null -ne $metadata.CreatedDate) {
                        # No power event found -- use creation date as fallback
                        $daysSincePowerOn = [math]::Round(((Get-Date) - $metadata.CreatedDate).TotalDays, 0)
                    }

                    if ($null -ne $daysSincePowerOn -and $daysSincePowerOn -ge $StaleThresholdDays) {
                        $isStale = $true
                        $staleReason = "Powered off for $daysSincePowerOn days (threshold: $StaleThresholdDays)"
                    }
                }

                # --- Check 2: Powered-on but idle indicators ---
                if (-not $isStale -and $metadata.PowerState -eq 'PoweredOn') {
                    # Check for missing VMware Tools heartbeat -- strong idle indicator
                    $toolsMissing = ($metadata.ToolsStatus -eq 'toolsNotInstalled' -or $metadata.ToolsStatus -eq 'toolsNotRunning')
                    $noNetwork = ($metadata.Networks.Count -eq 0 -or ($metadata.Networks.Count -eq 1 -and $metadata.Networks[0] -eq 'Disconnected'))

                    if ($toolsMissing -and $noNetwork) {
                        $isStale = $true
                        $staleReason = 'Powered on but no VMware Tools and no network -- possibly abandoned'
                        # Estimate powered on since creation or boot
                        if ($null -ne $metadata.LastPoweredOn) {
                            $daysSincePowerOn = [math]::Round(((Get-Date) - $metadata.LastPoweredOn).TotalDays, 0)
                        }
                    }
                }

                if (-not $isStale) { continue }

                # --- Calculate waste cost ---
                $cpuMonthlyCost = $metadata.NumCPU * $CpuCostPerHour * 24 * 30
                $ramMonthlyCost = $metadata.MemoryGB * $RamCostPerGBHour * 24 * 30
                $totalMonthlyWaste = [math]::Round($cpuMonthlyCost + $ramMonthlyCost, 2)

                # --- Generate recommendation ---
                $recommendation = Get-StaleVMRecommendation -DaysSincePowerOn $daysSincePowerOn `
                    -PowerState $metadata.PowerState `
                    -StorageGB $metadata.ProvisionedSpaceGB `
                    -StaleReason $staleReason

                $staleEntry = [PSCustomObject]@{
                    VMName                 = $vm.Name
                    PowerState             = $metadata.PowerState
                    DaysSincePowerOn       = $daysSincePowerOn
                    StaleReason            = $staleReason
                    CPU                    = $metadata.NumCPU
                    MemoryGB               = $metadata.MemoryGB
                    ProvisionedStorageGB   = $metadata.ProvisionedSpaceGB
                    UsedStorageGB          = $metadata.UsedSpaceGB
                    ToolsStatus            = $metadata.ToolsStatus
                    SnapshotCount          = $metadata.SnapshotCount
                    Cluster                = $metadata.Cluster
                    Datacenter             = $metadata.Datacenter
                    Host                   = $metadata.Host
                    CreatedDate            = $metadata.CreatedDate
                    LastPoweredOn          = $metadata.LastPoweredOn
                    Networks               = $metadata.Networks
                    EstimatedWastePerMonth = $totalMonthlyWaste
                    Recommendation         = $recommendation
                }
                $staleEntry.PSObject.TypeNames.Insert(0, 'VMAutoTagger.StaleVM')

                [void]$staleVMs.Add($staleEntry)
            }

            Write-Progress -Activity "Scanning for Stale VMs" -Completed
        }
        finally {
            if ($viConnection) {
                try { Disconnect-Hypervisor -Server $Server -Hypervisor $Hypervisor } catch { }
            }
        }

        # --- Generate HTML report ---
        if ($OutputPath -and $staleVMs.Count -gt 0) {
            Write-Verbose "Generating stale VM report: $OutputPath"
            $html = Build-StaleVMReportHtml -StaleVMs $staleVMs -Server $Server -StartTime $startTime `
                -ThresholdDays $StaleThresholdDays
            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 "Stale VM report saved to: $OutputPath" -ForegroundColor Green
            }
            catch {
                Write-Warning "Failed to save stale VM report: $_"
            }
        }
    }

    end {
        # Summary
        $totalWaste = ($staleVMs | Measure-Object -Property EstimatedWastePerMonth -Sum).Sum
        if ($null -eq $totalWaste) { $totalWaste = 0 }
        $totalStorage = ($staleVMs | Measure-Object -Property ProvisionedStorageGB -Sum).Sum
        if ($null -eq $totalStorage) { $totalStorage = 0 }

        Write-Host ""
        Write-Host "=== Stale VM Scan Summary ===" -ForegroundColor Cyan
        Write-Host " Stale VMs Found: $($staleVMs.Count)"
        Write-Host " Total Storage Waste: $([math]::Round($totalStorage, 1)) GB"
        Write-Host " Est. Monthly Waste: `$$([math]::Round($totalWaste, 2))"
        Write-Host " Threshold: $StaleThresholdDays days"
        Write-Host ""

        return @($staleVMs)
    }
}

function Get-StaleVMRecommendation {
    <#
    .SYNOPSIS
        Generates a remediation recommendation string for a stale VM.
    #>

    [CmdletBinding()]
    param(
        [int]$DaysSincePowerOn,
        [string]$PowerState,
        [double]$StorageGB,
        [string]$StaleReason
    )

    if ($null -eq $DaysSincePowerOn) { $DaysSincePowerOn = 0 }

    if ($DaysSincePowerOn -gt 180) {
        return 'DECOMMISSION: VM has been inactive for over 6 months. Verify with owner and delete to reclaim resources.'
    }
    elseif ($DaysSincePowerOn -gt 90) {
        return 'ARCHIVE: VM inactive for over 90 days. Export to OVA/OVF for cold storage and remove from cluster.'
    }
    elseif ($DaysSincePowerOn -gt 60) {
        return 'REVIEW: VM inactive for over 60 days. Contact VM owner to confirm whether it is still needed.'
    }
    elseif ($PowerState -eq 'PoweredOn') {
        return 'INVESTIGATE: VM is powered on but shows no activity. Verify it is functional and serving a purpose.'
    }
    else {
        return 'MONITOR: VM recently became stale. Check back after the next review cycle.'
    }
}

function Build-StaleVMReportHtml {
    <#
    .SYNOPSIS
        Builds the HTML report for stale VM findings.
    #>

    [CmdletBinding()]
    param(
        [array]$StaleVMs,
        [string]$Server,
        [datetime]$StartTime,
        [int]$ThresholdDays
    )

    $totalWaste = ($StaleVMs | Measure-Object -Property EstimatedWastePerMonth -Sum).Sum
    if ($null -eq $totalWaste) { $totalWaste = 0 }
    $totalStorage = ($StaleVMs | Measure-Object -Property ProvisionedStorageGB -Sum).Sum
    if ($null -eq $totalStorage) { $totalStorage = 0 }

    # Build VM rows using single-quoted concatenation
    $rowList = [System.Collections.ArrayList]::new()
    foreach ($svm in $StaleVMs) {
        $stateClass = switch ($svm.PowerState) { 'PoweredOn' { 'status-good' }; 'PoweredOff' { 'status-bad' }; default { 'status-warn' } }
        $lastOn = if ($null -ne $svm.LastPoweredOn) { '{0:yyyy-MM-dd}' -f $svm.LastPoweredOn } else { 'Unknown' }
        $daysStr = if ($null -ne $svm.DaysSincePowerOn) { [string]$svm.DaysSincePowerOn } else { 'N/A' }
        $wasteClass = if ($svm.EstimatedWastePerMonth -gt 100) { 'text-warning' } else { '' }
        $encRec = [System.Web.HttpUtility]::HtmlEncode($svm.Recommendation)
        [void]$rowList.Add(('<tr><td>' + $svm.VMName + '</td><td><span class="' + $stateClass + '">' + $svm.PowerState + '</span></td><td>' + $lastOn + '</td><td>' + $daysStr + '</td><td>' + $svm.CPU + '</td><td>' + $svm.MemoryGB + 'GB</td><td>' + $svm.ProvisionedStorageGB + 'GB</td><td class="' + $wasteClass + '">$' + $svm.EstimatedWastePerMonth + '/mo</td><td class="recommendation">' + $encRec + '</td></tr>'))
    }
    $vmRows = $rowList -join "`n"

    # Build waste distribution by recommendation category
    $recGroups = $StaleVMs | Group-Object { ($_.Recommendation -split ':')[0].Trim() }
    $distSb = [System.Text.StringBuilder]::new()
    foreach ($rg in ($recGroups | Sort-Object Count -Descending)) {
        $groupWaste = ($rg.Group | Measure-Object -Property EstimatedWastePerMonth -Sum).Sum
        $pct = if ($StaleVMs.Count -gt 0) { [math]::Round(($rg.Count / $StaleVMs.Count) * 100, 1) } else { 0 }
        [void]$distSb.Append(('<div class="bar-row"><span class="bar-label">' + $rg.Name + ' (' + $rg.Count + ')</span><div class="bar-track"><div class="bar-fill" style="width: ' + $pct + '%"></div></div><span class="bar-value">$' + [math]::Round($groupWaste, 2) + '/mo</span></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>Stale VM 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}
.stat-card.waste .value{color:#f85149}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem}
.card h2{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;vertical-align:top;font-size:.9rem}tr:hover{background:#1c2128}
.status-good{color:#3fb950}.status-bad{color:#f85149}.status-warn{color:#d29922}
.text-warning{color:#d29922;font-weight:600}
.recommendation{font-size:.8rem;color:#8b949e;max-width:300px}
.bar-chart{padding:.5rem 0}.bar-row{display:flex;align-items:center;margin-bottom:.5rem}
.bar-label{width:180px;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:100px;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>Stale VM Report</h1><div class="subtitle">Server: %%SERVER%% | Threshold: %%THRESHOLD%% days | Generated: %%TS%%</div></div>
<div class="stats-grid">
<div class="stat-card"><div class="value">%%COUNT%%</div><div class="label">Stale VMs</div></div>
<div class="stat-card waste"><div class="value">$%%WASTE%%</div><div class="label">Est. Monthly Waste</div></div>
<div class="stat-card"><div class="value">%%STORAGE%%GB</div><div class="label">Wasted Storage</div></div>
<div class="stat-card"><div class="value">%%THRESHOLD%%d</div><div class="label">Threshold</div></div>
</div>
<div class="card"><h2>Waste by Recommendation</h2><div class="bar-chart">%%DISTCHART%%</div></div>
<div class="card"><h2>Stale VM Details</h2>
<table><tr><th>VM Name</th><th>Power State</th><th>Last Powered On</th><th>Days</th><th>CPU</th><th>RAM</th><th>Storage</th><th>Est. Waste</th><th>Recommendation</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('%%THRESHOLD%%', $ThresholdDays.ToString())
    $html = $html.Replace('%%TS%%', $tsStr)
    $html = $html.Replace('%%COUNT%%', $StaleVMs.Count.ToString())
    $html = $html.Replace('%%WASTE%%', [math]::Round($totalWaste, 2).ToString())
    $html = $html.Replace('%%STORAGE%%', [math]::Round($totalStorage, 1).ToString())
    $html = $html.Replace('%%DISTCHART%%', $distSb.ToString())
    $html = $html.Replace('%%VMROWS%%', $vmRows)
    $html = $html.Replace('%%NOW%%', $nowStr)

    return $html
}