Common/ReportHelpers.ps1
|
<#
.SYNOPSIS Pure helper functions for HTML report generation. .DESCRIPTION Contains HTML escaping, badge rendering, SVG chart generation, column formatting, and smart data sorting functions used by Export-AssessmentReport.ps1 and its companion Build-SectionHtml.ps1 / Get-ReportTemplate.ps1 modules. Dot-source this file to make all helper functions available: . "$PSScriptRoot\ReportHelpers.ps1" .NOTES Author: Daren9m Extracted from Export-AssessmentReport.ps1 for maintainability (#235). #> # ------------------------------------------------------------------ # HTML helper functions # ------------------------------------------------------------------ function ConvertTo-HtmlSafe { param([string]$Text) if (-not $Text) { return '' } return $Text.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace('"', '"') } function Get-StatusBadge { param([string]$Status) switch ($Status) { 'Complete' { '<span class="badge badge-complete">Complete</span>' } 'Skipped' { '<span class="badge badge-skipped">Skipped</span>' } 'Failed' { '<span class="badge badge-failed">Failed</span>' } default { "<span class='badge'>$Status</span>" } } } function Format-ColumnHeader { param([string]$Name) if (-not $Name) { return $Name } # Insert space between lowercase/digit and uppercase: "createdDate" -> "created Date" # CRITICAL: Use -creplace (case-sensitive) -- default -replace is case-insensitive $spaced = $Name -creplace '([a-z\d])([A-Z])', '$1 $2' # Insert space between consecutive uppercase and uppercase+lowercase: "MFAStatus" -> "MFA Status" $spaced = $spaced -creplace '([A-Z]+)([A-Z][a-z])', '$1 $2' return $spaced } function Get-SeverityBadge { param([string]$Severity) switch ($Severity) { 'ERROR' { '<span class="badge badge-failed">ERROR</span>' } 'WARNING' { '<span class="badge badge-warning">WARNING</span>' } 'INFO' { '<span class="badge badge-info">INFO</span>' } default { "<span class='badge'>$Severity</span>" } } } function Get-AssetBase64 { param([string]$Directory, [string[]]$Patterns) foreach ($pattern in $Patterns) { $file = Get-ChildItem -Path $Directory -Filter $pattern -ErrorAction SilentlyContinue | Select-Object -First 1 if ($file) { $bytes = [System.IO.File]::ReadAllBytes($file.FullName) $ext = $file.Extension.TrimStart('.').ToLower() $mime = switch ($ext) { 'jpg' { 'image/jpeg' } 'jpeg' { 'image/jpeg' } 'svg' { 'image/svg+xml' } default { 'image/png' } } return @{ Base64 = [Convert]::ToBase64String($bytes); Mime = $mime } } } return $null } # ------------------------------------------------------------------ # SVG chart helpers -- inline charts for the HTML report # ------------------------------------------------------------------ function Get-SvgDonut { param( [double]$Percentage, [string]$CssClass = 'success', [string]$Label = '', [int]$Size = 120, [int]$StrokeWidth = 10 ) $radius = ($Size / 2) - $StrokeWidth $circumference = [math]::Round(2 * [math]::PI * $radius, 2) $dashOffset = [math]::Round($circumference * (1 - ($Percentage / 100)), 2) $center = $Size / 2 $displayVal = if ($Label) { $Label } else { "$Percentage%" } return @" <svg class='donut-chart' width='$Size' height='$Size' viewBox='0 0 $Size $Size' role='img' aria-label='Chart showing $displayVal'> <circle class='donut-track' cx='$center' cy='$center' r='$radius' fill='none' stroke-width='$StrokeWidth'/> <circle class='donut-fill donut-$CssClass' cx='$center' cy='$center' r='$radius' fill='none' stroke-width='$StrokeWidth' stroke-dasharray='$circumference' stroke-dashoffset='$dashOffset' stroke-linecap='round' transform='rotate(-90 $center $center)'/> <text class='donut-text' x='$center' y='$center' text-anchor='middle' dominant-baseline='central'>$displayVal</text> </svg> "@ } function Get-SvgMultiDonut { param( [array]$Segments, [string]$CenterLabel = '', [int]$Size = 130, [int]$StrokeWidth = 11 ) $radius = ($Size / 2) - $StrokeWidth $circumference = 2 * [math]::PI * $radius $center = $Size / 2 $svg = "<svg class='donut-chart' width='$Size' height='$Size' viewBox='0 0 $Size $Size' role='img' aria-label='Chart showing $CenterLabel'>" $svg += "<circle class='donut-track' cx='$center' cy='$center' r='$radius' fill='none' stroke-width='$StrokeWidth'/>" # Filter to visible segments and track cumulative arc to eliminate rounding gaps $visibleSegs = @($Segments | Where-Object { $_.Pct -gt 0 }) $offset = 0 $cumulativeArc = 0 for ($i = 0; $i -lt $visibleSegs.Count; $i++) { $seg = $visibleSegs[$i] $rotDeg = [math]::Round(($offset / 100) * 360 - 90, 4) if ($i -eq $visibleSegs.Count - 1) { # Last segment closes the circle exactly -- no rounding gap possible $arcLen = [math]::Round($circumference - $cumulativeArc, 4) } else { $arcLen = [math]::Round(($seg.Pct / 100) * $circumference, 4) } $gapLen = [math]::Round($circumference - $arcLen, 4) $svg += "<circle class='donut-fill donut-$($seg.Css)' data-segment='$($seg.Css)' cx='$center' cy='$center' r='$radius' fill='none' stroke-width='$StrokeWidth' stroke-dasharray='$arcLen $gapLen' transform='rotate($rotDeg $center $center)'/>" $offset += $seg.Pct $cumulativeArc += $arcLen } $svg += "<text class='donut-text donut-text-sm' x='$center' y='$center' text-anchor='middle' dominant-baseline='central'>$CenterLabel</text>" $svg += "</svg>" return $svg } function Get-SvgHorizontalBar { param( [array]$Segments ) $barHtml = "<div class='hbar-chart'>" foreach ($seg in $Segments) { if ($seg.Pct -gt 0) { $barHtml += "<div class='hbar-segment hbar-$($seg.Css)' style='width: $($seg.Pct)%;' title='$($seg.Label): $($seg.Count)'><span class='hbar-label'>$($seg.Count)</span></div>" } } $barHtml += "</div>" return $barHtml } function Get-SvgStackedBar { <# .SYNOPSIS Generates an SVG stacked horizontal bar chart with one row per service area. .DESCRIPTION Renders a multi-row SVG chart where each row is a horizontal stacked bar showing Pass/Fail/Warning/Review counts for a service area. Uses CSS variables for fill colors to support light and dark mode. .PARAMETER Rows Array of hashtables with keys: Label, Pass, Fail, Warning, Review, Total. .PARAMETER Width SVG width in pixels. .PARAMETER BarHeight Height of each bar in pixels. .PARAMETER Gap Vertical gap between rows in pixels. .PARAMETER LabelWidth Width reserved for row labels in pixels. .EXAMPLE Get-SvgStackedBar -Rows @(@{Label='Identity'; Pass=45; Fail=3; Warning=5; Review=2; Total=55}) #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [array]$Rows, [int]$Width = 600, [int]$BarHeight = 22, [int]$Gap = 6, [int]$LabelWidth = 110 ) $legendHeight = 28 $topPadding = 4 $totalHeight = $topPadding + ($Rows.Count * ($BarHeight + $Gap)) - $Gap + $legendHeight + 8 $barWidth = $Width - $LabelWidth - 50 # reserve space for count label on right $svg = "<svg xmlns='http://www.w3.org/2000/svg' width='$Width' height='$totalHeight' viewBox='0 0 $Width $totalHeight' role='img' aria-label='Service area breakdown chart'>" # Legend at top $legendY = $topPadding $legendItems = @( @{ Label = 'Pass'; Color = 'var(--m365a-success)' }, @{ Label = 'Fail'; Color = 'var(--m365a-danger)' }, @{ Label = 'Warning'; Color = 'var(--m365a-warning)' }, @{ Label = 'Review'; Color = 'var(--m365a-review)' } ) $legendX = $LabelWidth foreach ($item in $legendItems) { $svg += "<rect x='$legendX' y='$legendY' width='10' height='10' rx='2' fill='$($item.Color)'/>" $textX = $legendX + 14 $svg += "<text x='$textX' y='$($legendY + 9)' font-family='Inter, sans-serif' font-size='9' fill='var(--m365a-medium-gray)'>$($item.Label)</text>" $legendX += 70 } $barStartY = $topPadding + $legendHeight foreach ($row in $Rows) { $rowIndex = [array]::IndexOf($Rows, $row) $y = $barStartY + ($rowIndex * ($BarHeight + $Gap)) $textY = $y + [math]::Round($BarHeight / 2, 0) + 4 # Derive navigation target from label: lowercase, replace non-alphanumeric with dash $navTarget = 'section-' + ($row.Label -replace '[^a-zA-Z0-9]', '-').ToLower() # Wrap entire row in a clickable group with data-nav $svg += "<g class='chart-nav-link' data-nav='$navTarget' role='link' tabindex='0' aria-label='Navigate to $($row.Label) section'>" # Row label $svg += "<text x='$($LabelWidth - 8)' y='$textY' font-family='Inter, sans-serif' font-size='10' fill='var(--m365a-text)' text-anchor='end'>$($row.Label)</text>" $total = [int]$row.Total if ($total -eq 0) { # Empty bar track $svg += "<rect x='$LabelWidth' y='$y' width='$barWidth' height='$BarHeight' rx='4' fill='var(--m365a-border)' opacity='0.3'/>" $svg += "</g>" continue } # Background track $svg += "<rect x='$LabelWidth' y='$y' width='$barWidth' height='$BarHeight' rx='4' fill='var(--m365a-border)' opacity='0.15'/>" # Stacked segments: Pass, Fail, Warning, Review $segments = @( @{ Count = [int]$row.Pass; Color = 'var(--m365a-success)'; Name = 'Pass' }, @{ Count = [int]$row.Fail; Color = 'var(--m365a-danger)'; Name = 'Fail' }, @{ Count = [int]$row.Warning; Color = 'var(--m365a-warning)'; Name = 'Warning' }, @{ Count = [int]$row.Review; Color = 'var(--m365a-review)'; Name = 'Review' } ) $xOffset = $LabelWidth $isFirst = $true $isLast = $false $visibleSegs = @($segments | Where-Object { $_.Count -gt 0 }) $segIndex = 0 foreach ($seg in $visibleSegs) { $segWidth = [math]::Round(($seg.Count / $total) * $barWidth, 1) if ($segWidth -lt 1) { $segWidth = 1 } $segIndex++ $isLast = ($segIndex -eq $visibleSegs.Count) # Use clip-path for rounded corners on first/last segments if ($isFirst -and $isLast) { $svg += "<rect x='$xOffset' y='$y' width='$segWidth' height='$BarHeight' rx='4' fill='$($seg.Color)'>" } elseif ($isFirst) { $clipId = "clip-first-$rowIndex" $svg += "<clipPath id='$clipId'><rect x='$xOffset' y='$y' width='$($segWidth + 4)' height='$BarHeight' rx='4'/></clipPath>" $svg += "<rect x='$xOffset' y='$y' width='$segWidth' height='$BarHeight' fill='$($seg.Color)' clip-path='url(#$clipId)'>" } elseif ($isLast) { $clipId = "clip-last-$rowIndex" $svg += "<clipPath id='$clipId'><rect x='$($xOffset - 4)' y='$y' width='$($segWidth + 4)' height='$BarHeight' rx='4'/></clipPath>" $svg += "<rect x='$xOffset' y='$y' width='$segWidth' height='$BarHeight' fill='$($seg.Color)' clip-path='url(#$clipId)'>" } else { $svg += "<rect x='$xOffset' y='$y' width='$segWidth' height='$BarHeight' fill='$($seg.Color)'>" } $svg += "<title>$($seg.Name): $($seg.Count)</title></rect>" $xOffset += $segWidth $isFirst = $false } # Total count label on right $countX = $LabelWidth + $barWidth + 6 $svg += "<text x='$countX' y='$textY' font-family='Inter, sans-serif' font-size='10' fill='var(--m365a-medium-gray)'>$total</text>" # Close clickable row group $svg += "</g>" } $svg += "</svg>" return $svg } # ------------------------------------------------------------------ # Smart sorting helper -- prioritize actionable rows # ------------------------------------------------------------------ function Get-SmartSortedData { param( [array]$Data, [string]$CollectorName ) if (-not $Data -or $Data.Count -le 1) { return $Data } $columns = @($Data[0].PSObject.Properties.Name) # Security Config collectors: sort non-passing items first if ($columns -contains 'Status' -and $columns -contains 'CheckId') { $statusPriority = @{ 'Fail' = 0; 'Warning' = 1; 'Review' = 2; 'Unknown' = 3; 'Pass' = 4 } return @($Data | Sort-Object -Property @{ Expression = { if ($null -ne $statusPriority[$_.Status]) { $statusPriority[$_.Status] } else { 5 } } }, 'Category', 'Setting') } # MFA Report: show users without MFA enforcement first, admins first if ($CollectorName -match 'MFA') { $mfaStatusCol = $columns | Where-Object { $_ -match 'MFAStatus|MfaStatus|StrongAuth' } $adminCol = $columns | Where-Object { $_ -match 'Admin|Role|IsAdmin' } if ($mfaStatusCol) { return @($Data | Sort-Object -Property @{ Expression = { if ($_.$mfaStatusCol -match 'Enforced|Enabled') { 1 } else { 0 } } }, @{ Expression = { if ($adminCol -and $_.$adminCol -and $_.$adminCol -ne 'None' -and $_.$adminCol -ne '' -and $_.$adminCol -ne 'False') { 0 } else { 1 } } }) } } # Device Summary: non-compliant and non-enrolled devices first if ($CollectorName -match 'Device') { $complianceCol = $columns | Where-Object { $_ -match 'Complian' } $enrollCol = $columns | Where-Object { $_ -match 'Enroll|Managed|MDM' } if ($complianceCol) { return @($Data | Sort-Object -Property @{ Expression = { if ($_.$complianceCol -match 'Compliant|compliant') { 1 } else { 0 } } }) } if ($enrollCol) { return @($Data | Sort-Object -Property @{ Expression = { if ($_.$enrollCol -match 'True|Yes|Enrolled') { 1 } else { 0 } } }) } } # User Summary: disabled and inactive accounts first if ($CollectorName -match 'User Summary') { $enabledCol = $columns | Where-Object { $_ -match 'AccountEnabled|Enabled' } if ($enabledCol) { $signInCol = $columns | Where-Object { $_ -match 'LastSignIn|LastLogin' } if ($signInCol) { return @($Data | Sort-Object -Property @{ Expression = { if ($_.$enabledCol -match 'True|Yes') { 1 } else { 0 } } }, $signInCol) } return @($Data | Sort-Object -Property @{ Expression = { if ($_.$enabledCol -match 'True|Yes') { 1 } else { 0 } } }) } } # Security Config collectors without CIS (Status column present) if ($columns -contains 'Status' -and $columns -contains 'RecommendedValue') { $statusPriority = @{ 'Fail' = 0; 'Warning' = 1; 'Review' = 2; 'Unknown' = 3; 'Pass' = 4 } return @($Data | Sort-Object -Property @{ Expression = { if ($null -ne $statusPriority[$_.Status]) { $statusPriority[$_.Status] } else { 5 } } }) } return $Data } |