Private/New-HtmlDashboard.ps1
|
function New-HtmlDashboard { <# .SYNOPSIS Generates an HTML dashboard report with a dark theme and gold accent. .DESCRIPTION Creates a self-contained HTML file with license optimization results displayed in a dark-themed dashboard. Features a prominent savings summary card at the top with estimated annual savings in green text, followed by data tables for license inventory, underutilized licenses, inactive users, and savings breakdown. .PARAMETER Title Dashboard title displayed in the header. .PARAMETER LicenseInventory Collection of license inventory objects from Get-LicenseInventory. .PARAMETER UnderutilizedLicenses Collection of underutilized license objects from Get-UnderutilizedLicenses. .PARAMETER InactiveUsers Collection of inactive licensed user objects from Get-InactiveLicensedUsers. .PARAMETER SavingsReport Collection of savings summary objects from Get-LicenseSavingsReport. .PARAMETER OutputPath Full path to the output HTML file. .OUTPUTS System.IO.FileInfo #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$Title = 'M365 License Optimization Report', [Parameter(Mandatory = $false)] [AllowNull()] [object[]]$LicenseInventory, [Parameter(Mandatory = $false)] [AllowNull()] [object[]]$UnderutilizedLicenses, [Parameter(Mandatory = $false)] [AllowNull()] [object[]]$InactiveUsers, [Parameter(Mandatory = $false)] [AllowNull()] [object[]]$SavingsReport, [Parameter(Mandatory = $true)] [string]$OutputPath ) # Calculate headline savings figure $annualSavings = 0 if ($SavingsReport) { $annualSavings = ($SavingsReport | Measure-Object -Property AnnualSavings -Sum -ErrorAction SilentlyContinue).Sum if (-not $annualSavings) { $annualSavings = 0 } } $totalMonthlySpend = 0 if ($SavingsReport) { $spendRow = $SavingsReport | Where-Object { $_.Category -eq 'Current Spend' } if ($spendRow) { $totalMonthlySpend = $spendRow.MonthlySavings } } $generatedDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' # Build HTML table helper function ConvertTo-HtmlTable { param([object[]]$Data, [string[]]$Properties) if (-not $Data -or $Data.Count -eq 0) { return '<p class="empty">No data available.</p>' } $sb = [System.Text.StringBuilder]::new() [void]$sb.Append('<div class="table-wrapper"><table><thead><tr>') foreach ($prop in $Properties) { [void]$sb.Append("<th>$prop</th>") } [void]$sb.Append('</tr></thead><tbody>') foreach ($row in $Data) { [void]$sb.Append('<tr>') foreach ($prop in $Properties) { $value = $row.$prop if ($null -eq $value) { $value = '' } # Format currency values if ($prop -match 'Cost|Savings|Spend' -and $value -is [double]) { $value = '${0:N2}' -f $value } if ($prop -match 'Percent') { $value = '{0:N1}%' -f $value } # Highlight findings $class = '' if ($prop -eq 'Finding') { switch -Wildcard ($value) { 'OVER-PROVISIONED*' { $class = ' class="warn"' } 'DOWNGRADE*' { $class = ' class="warn"' } 'NO SIGN-IN*' { $class = ' class="alert"' } 'INACTIVE*' { $class = ' class="alert"' } 'DISABLED*' { $class = ' class="alert"' } 'NEVER SIGNED IN*' { $class = ' class="alert"' } 'GUEST*' { $class = ' class="warn"' } 'FULLY UTILIZED*' { $class = ' class="good"' } } } [void]$sb.Append("<td$class>$([System.Web.HttpUtility]::HtmlEncode($value))</td>") } [void]$sb.Append('</tr>') } [void]$sb.Append('</tbody></table></div>') return $sb.ToString() } # Build section tables $inventoryHtml = ConvertTo-HtmlTable -Data $LicenseInventory -Properties @( 'SkuPartNumber', 'FriendlyName', 'TotalLicenses', 'AssignedLicenses', 'AvailableLicenses', 'UtilizationPercent', 'MonthlyCost', 'Finding' ) $underutilizedHtml = ConvertTo-HtmlTable -Data $UnderutilizedLicenses -Properties @( 'UserPrincipalName', 'DisplayName', 'AssignedLicense', 'LastSignIn', 'DaysSinceSignIn', 'ServicesUsed', 'RecommendedLicense', 'MonthlySavings', 'Finding' ) $inactiveHtml = ConvertTo-HtmlTable -Data $InactiveUsers -Properties @( 'UserPrincipalName', 'DisplayName', 'AccountEnabled', 'UserType', 'AssignedLicenses', 'LicenseCost', 'LastSignIn', 'DaysSinceSignIn', 'Finding' ) $savingsHtml = ConvertTo-HtmlTable -Data $SavingsReport -Properties @( 'Category', 'Action', 'Count', 'MonthlySavings', 'AnnualSavings' ) $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>$([System.Web.HttpUtility]::HtmlEncode($Title))</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 20px; line-height: 1.6; } .header { text-align: center; padding: 30px 20px; border-bottom: 3px solid #d29922; margin-bottom: 30px; } .header h1 { color: #d29922; font-size: 2em; margin-bottom: 5px; } .header .subtitle { color: #888; font-size: 0.9em; } .savings-card { background: linear-gradient(135deg, #1e3a2f 0%, #1a2e1a 100%); border: 2px solid #2ecc71; border-radius: 12px; padding: 30px; text-align: center; margin-bottom: 30px; box-shadow: 0 4px 20px rgba(46, 204, 113, 0.15); } .savings-card .label { font-size: 1.1em; color: #a0a0a0; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 10px; } .savings-card .amount { font-size: 3.5em; font-weight: 700; color: #2ecc71; text-shadow: 0 0 20px rgba(46, 204, 113, 0.3); } .savings-card .detail { font-size: 0.95em; color: #888; margin-top: 10px; } .section { background: #16213e; border-radius: 10px; padding: 25px; margin-bottom: 25px; border-left: 4px solid #d29922; } .section h2 { color: #d29922; margin-bottom: 15px; font-size: 1.3em; } .section .count { color: #888; font-size: 0.85em; margin-bottom: 15px; } .table-wrapper { overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 0.85em; } th { background: #0f3460; color: #d29922; padding: 10px 12px; text-align: left; font-weight: 600; white-space: nowrap; border-bottom: 2px solid #d29922; } td { padding: 8px 12px; border-bottom: 1px solid #1a1a3e; } tr:hover { background: #1a2a4e; } td.warn { color: #f39c12; font-weight: 600; } td.alert { color: #e74c3c; font-weight: 600; } td.good { color: #2ecc71; font-weight: 600; } .empty { color: #666; font-style: italic; padding: 20px; text-align: center; } .footer { text-align: center; color: #555; font-size: 0.8em; margin-top: 30px; padding-top: 20px; border-top: 1px solid #333; } @media (max-width: 768px) { .savings-card .amount { font-size: 2.2em; } body { padding: 10px; } } </style> </head> <body> <div class="header"> <h1>$([System.Web.HttpUtility]::HtmlEncode($Title))</h1> <div class="subtitle">Generated $generatedDate | M365-LicenseOptimizer</div> </div> <div class="savings-card"> <div class="label">Estimated Annual Savings</div> <div class="amount">$('${0:N0}' -f $annualSavings)</div> <div class="detail">Based on list pricing estimates. Actual savings may vary by agreement type (EA/CSP/MOSP).</div> </div> <div class="section"> <h2>Savings Summary</h2> <div class="count">Breakdown of optimization opportunities</div> $savingsHtml </div> <div class="section"> <h2>License Inventory</h2> <div class="count">$( if ($LicenseInventory) { "$($LicenseInventory.Count) SKU(s)" } else { '0 SKUs' } )</div> $inventoryHtml </div> <div class="section"> <h2>Underutilized Licenses</h2> <div class="count">$( if ($UnderutilizedLicenses) { "$($UnderutilizedLicenses.Count) user(s) identified" } else { '0 users' } )</div> $underutilizedHtml </div> <div class="section"> <h2>Inactive Licensed Users</h2> <div class="count">$( if ($InactiveUsers) { "$($InactiveUsers.Count) user(s) identified" } else { '0 users' } )</div> $inactiveHtml </div> <div class="footer"> M365-LicenseOptimizer © 2026 Larry Roberts | Read-only analysis — no changes made to your tenant </div> </body> </html> "@ # Ensure output directory exists $outputDir = Split-Path -Path $OutputPath -Parent if ($outputDir -and -not (Test-Path -Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } $html | Out-File -FilePath $OutputPath -Encoding UTF8 -Force Write-Verbose "Dashboard written to $OutputPath" return (Get-Item -Path $OutputPath) } |