Private/New-HtmlDashboard.ps1
|
function New-HtmlDashboard { <# .SYNOPSIS Generates a dark-themed HTML dashboard report from GPO audit findings. .DESCRIPTION Takes structured audit results from the GPO health check functions and renders them into a self-contained HTML file with a dark theme and purple accent styling. Each audit category becomes a collapsible section with a findings summary table. This is a private helper function used by Invoke-GPOHealthAudit. It is not exported from the module. .PARAMETER Title The report title displayed in the header. Defaults to 'GPO Health Audit Report'. .PARAMETER Sections An array of hashtables, each representing a report section. Expected keys: - Name: Section heading text - Summary: Brief description of what this section checks - Findings: Array of PSCustomObjects to render as a table - Status: Overall section status - 'OK', 'WARNING', or 'CRITICAL' .PARAMETER OutputPath Full file path for the generated HTML file. .PARAMETER DomainName The Active Directory domain name to display in the report header. .OUTPUTS [System.IO.FileInfo] The generated HTML file object. #> [CmdletBinding()] param( [Parameter()] [string]$Title = 'GPO Health Audit Report', [Parameter(Mandatory)] [hashtable[]]$Sections, [Parameter(Mandatory)] [string]$OutputPath, [Parameter()] [string]$DomainName = $env:USERDNSDOMAIN ) begin { Write-Verbose "Generating HTML dashboard: $OutputPath" } process { # --- Build summary counts --- $TotalFindings = 0 $CriticalCount = 0 $WarningCount = 0 $OkCount = 0 foreach ($Section in $Sections) { $FindingCount = @($Section.Findings).Count if ($Section.Status -eq 'CRITICAL') { $CriticalCount++ } elseif ($Section.Status -eq 'WARNING') { $WarningCount++ } else { $OkCount++ } $TotalFindings += $FindingCount } $GeneratedDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' # --- Build section HTML --- $SectionHtml = [System.Text.StringBuilder]::new() foreach ($Section in $Sections) { $StatusClass = switch ($Section.Status) { 'CRITICAL' { 'status-critical' } 'WARNING' { 'status-warning' } default { 'status-ok' } } $StatusIcon = switch ($Section.Status) { 'CRITICAL' { '❌' } 'WARNING' { '⚠' } default { '✅' } } [void]$SectionHtml.AppendLine(" <div class='section'>") [void]$SectionHtml.AppendLine(" <div class='section-header'>") [void]$SectionHtml.AppendLine(" <h2>$($Section.Name)</h2>") [void]$SectionHtml.AppendLine(" <span class='badge $StatusClass'>$StatusIcon $($Section.Status) — $(@($Section.Findings).Count) finding(s)</span>") [void]$SectionHtml.AppendLine(" </div>") [void]$SectionHtml.AppendLine(" <p class='section-summary'>$($Section.Summary)</p>") if (@($Section.Findings).Count -gt 0) { # Determine columns from the first object $Columns = @($Section.Findings)[0].PSObject.Properties | Select-Object -ExpandProperty Name [void]$SectionHtml.AppendLine(" <div class='table-wrapper'>") [void]$SectionHtml.AppendLine(" <table>") [void]$SectionHtml.AppendLine(" <thead><tr>") foreach ($Col in $Columns) { [void]$SectionHtml.AppendLine(" <th>$Col</th>") } [void]$SectionHtml.AppendLine(" </tr></thead>") [void]$SectionHtml.AppendLine(" <tbody>") foreach ($Row in $Section.Findings) { [void]$SectionHtml.AppendLine(" <tr>") foreach ($Col in $Columns) { $Value = $Row.$Col if ($null -eq $Value) { $Value = '—' } # Highlight finding severity in cells $CellClass = '' if ($Col -eq 'Finding') { $CellClass = switch -Wildcard ($Value) { '*CRITICAL*' { " class='cell-critical'" } '*WARNING*' { " class='cell-warning'" } '*UNLINKED*' { " class='cell-warning'" } '*EMPTY*' { " class='cell-warning'" } '*STALE*' { " class='cell-warning'" } default { '' } } } [void]$SectionHtml.AppendLine(" <td$CellClass>$Value</td>") } [void]$SectionHtml.AppendLine(" </tr>") } [void]$SectionHtml.AppendLine(" </tbody>") [void]$SectionHtml.AppendLine(" </table>") [void]$SectionHtml.AppendLine(" </div>") } else { [void]$SectionHtml.AppendLine(" <p class='no-findings'>No issues detected.</p>") } [void]$SectionHtml.AppendLine(" </div>") } # --- Assemble full HTML --- $Html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>$Title</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.6; padding: 2rem; } .dashboard { max-width: 1400px; margin: 0 auto; } .header { background: linear-gradient(135deg, #161b22 0%, #1a1230 100%); border: 1px solid #a371f7; border-radius: 8px; padding: 2rem; margin-bottom: 2rem; text-align: center; } .header h1 { color: #a371f7; font-size: 1.8rem; margin-bottom: 0.5rem; } .header .subtitle { color: #8b949e; font-size: 0.95rem; } .summary-bar { display: flex; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap; } .summary-card { flex: 1; min-width: 180px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.2rem; text-align: center; } .summary-card .label { color: #8b949e; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; } .summary-card .value { font-size: 2rem; font-weight: 700; margin-top: 0.3rem; } .summary-card .value.critical { color: #f85149; } .summary-card .value.warning { color: #d29922; } .summary-card .value.ok { color: #3fb950; } .summary-card .value.total { color: #a371f7; } .section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; } .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; } .section-header h2 { color: #a371f7; font-size: 1.25rem; } .section-summary { color: #8b949e; font-size: 0.9rem; margin-bottom: 1rem; } .badge { padding: 0.3rem 0.8rem; border-radius: 20px; font-size: 0.8rem; font-weight: 600; } .status-critical { background: rgba(248, 81, 73, 0.15); color: #f85149; border: 1px solid #f8514966; } .status-warning { background: rgba(210, 153, 34, 0.15); color: #d29922; border: 1px solid #d2992266; } .status-ok { background: rgba(63, 185, 80, 0.15); color: #3fb950; border: 1px solid #3fb95066; } .table-wrapper { overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } thead th { background: #21262d; color: #a371f7; padding: 0.6rem 0.8rem; text-align: left; border-bottom: 2px solid #a371f733; white-space: nowrap; } tbody td { padding: 0.55rem 0.8rem; border-bottom: 1px solid #21262d; } tbody tr:hover { background: #1c2129; } .cell-critical { color: #f85149; font-weight: 600; } .cell-warning { color: #d29922; font-weight: 600; } .no-findings { color: #3fb950; font-style: italic; padding: 1rem 0; } .footer { text-align: center; color: #484f58; font-size: 0.8rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #21262d; } @media (max-width: 768px) { body { padding: 1rem; } .summary-bar { flex-direction: column; } } </style> </head> <body> <div class="dashboard"> <div class="header"> <h1>$Title</h1> <div class="subtitle">Domain: $DomainName — Generated: $GeneratedDate</div> </div> <div class="summary-bar"> <div class="summary-card"> <div class="label">Total Findings</div> <div class="value total">$TotalFindings</div> </div> <div class="summary-card"> <div class="label">Critical Sections</div> <div class="value critical">$CriticalCount</div> </div> <div class="summary-card"> <div class="label">Warning Sections</div> <div class="value warning">$WarningCount</div> </div> <div class="summary-card"> <div class="label">OK Sections</div> <div class="value ok">$OkCount</div> </div> </div> $($SectionHtml.ToString()) <div class="footer"> GPO-HealthAudit v1.0.0 — Read-only audit, no GPOs were modified — $GeneratedDate </div> </div> </body> </html> "@ try { $Html | Out-File -FilePath $OutputPath -Encoding UTF8 -Force $Result = Get-Item -Path $OutputPath Write-Verbose "Dashboard written to: $($Result.FullName) ($([math]::Round($Result.Length / 1KB, 1)) KB)" return $Result } catch { Write-Error "Failed to write HTML dashboard to '$OutputPath': $_" } } } |