Private/New-HtmlDashboard.ps1
|
function New-HtmlDashboard { <# .SYNOPSIS Generates a dark theme HTML drift report with violet/purple accent. .DESCRIPTION Creates a comprehensive HTML dashboard showing documentation drift analysis with donut chart visualization, summary cards, drift details, verified facts, and coverage gaps. #> [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject]$FactsDatabase, [Parameter()] [string]$OutputPath, [Parameter()] [string]$Title = 'Documentation Drift Report' ) $reportDate = Get-Date -Format 'yyyy-MM-dd HH:mm' $meta = $FactsDatabase.metadata $facts = $FactsDatabase.facts # Aggregate claim counts $allClaims = @() foreach ($fact in $facts) { foreach ($claim in $fact.claims) { $allClaims += [PSCustomObject]@{ FactId = $fact.id SourceDocument = $fact.source_document SourceText = $fact.source_text Category = $fact.category ClaimType = $claim.claim_type Subject = $claim.subject ExpectedValue = $claim.expected_value ActualValue = $claim.actual_value Status = $claim.status Method = $claim.verification_method LastChecked = $claim.last_checked } } } $verified = @($allClaims | Where-Object { $_.Status -eq 'verified' }) $drift = @($allClaims | Where-Object { $_.Status -eq 'drift' }) $unreachable = @($allClaims | Where-Object { $_.Status -eq 'unreachable' }) $unverifiable = @($allClaims | Where-Object { $_.Status -eq 'unverifiable' }) $pending = @($allClaims | Where-Object { $_.Status -eq 'pending' }) $total = $allClaims.Count # Calculate percentages for donut chart $pctVerified = if ($total -gt 0) { [Math]::Round(($verified.Count / $total) * 100) } else { 0 } $pctDrift = if ($total -gt 0) { [Math]::Round(($drift.Count / $total) * 100) } else { 0 } $pctUnreachable = if ($total -gt 0) { [Math]::Round(($unreachable.Count / $total) * 100) } else { 0 } $pctUnverifiable = if ($total -gt 0) { [Math]::Round(($unverifiable.Count / $total) * 100) } else { 0 } $pctPending = if ($total -gt 0) { 100 - $pctVerified - $pctDrift - $pctUnreachable - $pctUnverifiable } else { 0 } # Build drift rows $driftRowsHtml = '' foreach ($d in $drift) { $driftRowsHtml += @" <tr> <td><span class="category-badge">$($d.Category)</span></td> <td><strong>$([System.Web.HttpUtility]::HtmlEncode($d.Subject))</strong></td> <td>$([System.Web.HttpUtility]::HtmlEncode($d.ClaimType))</td> <td class="drift-expected">$([System.Web.HttpUtility]::HtmlEncode($d.ExpectedValue))</td> <td class="drift-actual">$([System.Web.HttpUtility]::HtmlEncode($d.ActualValue))</td> <td class="source-doc">$([System.Web.HttpUtility]::HtmlEncode($d.SourceDocument))</td> </tr> "@ } # Build verified rows $verifiedRowsHtml = '' foreach ($v in $verified) { $verifiedRowsHtml += @" <tr> <td><span class="category-badge">$($v.Category)</span></td> <td><strong>$([System.Web.HttpUtility]::HtmlEncode($v.Subject))</strong></td> <td>$([System.Web.HttpUtility]::HtmlEncode($v.ClaimType))</td> <td>$([System.Web.HttpUtility]::HtmlEncode($v.ExpectedValue))</td> <td class="verified-check">✓</td> <td class="source-doc">$([System.Web.HttpUtility]::HtmlEncode($v.SourceDocument))</td> </tr> "@ } # Build unreachable rows $unreachableRowsHtml = '' foreach ($u in $unreachable) { $unreachableRowsHtml += @" <tr> <td><span class="category-badge">$($u.Category)</span></td> <td><strong>$([System.Web.HttpUtility]::HtmlEncode($u.Subject))</strong></td> <td>$([System.Web.HttpUtility]::HtmlEncode($u.ClaimType))</td> <td>$([System.Web.HttpUtility]::HtmlEncode($u.ExpectedValue))</td> <td>$([System.Web.HttpUtility]::HtmlEncode($u.ActualValue))</td> <td class="source-doc">$([System.Web.HttpUtility]::HtmlEncode($u.SourceDocument))</td> </tr> "@ } # Source documents list $sourceDocsHtml = '' $sourceGroups = $allClaims | Group-Object -Property SourceDocument foreach ($sg in $sourceGroups) { $sgVerified = @($sg.Group | Where-Object { $_.Status -eq 'verified' }).Count $sgDrift = @($sg.Group | Where-Object { $_.Status -eq 'drift' }).Count $sgTotal = $sg.Count $sourceDocsHtml += @" <div class="source-card"> <h4>$([System.Web.HttpUtility]::HtmlEncode($sg.Name))</h4> <p>$sgTotal claims — <span class="text-green">$sgVerified verified</span>, <span class="text-red">$sgDrift drift</span></p> </div> "@ } # Category breakdown $categoryHtml = '' $categoryGroups = $allClaims | Group-Object -Property Category foreach ($cg in $categoryGroups) { $cgVerified = @($cg.Group | Where-Object { $_.Status -eq 'verified' }).Count $cgDrift = @($cg.Group | Where-Object { $_.Status -eq 'drift' }).Count $categoryHtml += @" <div class="category-card"> <h4>$($cg.Name)</h4> <div class="mini-bar"> <div class="mini-bar-verified" style="width: $(if($cg.Count -gt 0){[Math]::Round(($cgVerified/$cg.Count)*100)}else{0})%"></div> <div class="mini-bar-drift" style="width: $(if($cg.Count -gt 0){[Math]::Round(($cgDrift/$cg.Count)*100)}else{0})%"></div> </div> <p>$($cg.Count) facts — $cgVerified ok, $cgDrift drift</p> </div> "@ } # Conic gradient segments for donut chart $segments = @() $runningPct = 0 if ($pctVerified -gt 0) { $segments += "#3fb950 ${runningPct}% $($runningPct + $pctVerified)%" $runningPct += $pctVerified } if ($pctDrift -gt 0) { $segments += "#f85149 ${runningPct}% $($runningPct + $pctDrift)%" $runningPct += $pctDrift } if ($pctUnreachable -gt 0) { $segments += "#d29922 ${runningPct}% $($runningPct + $pctUnreachable)%" $runningPct += $pctUnreachable } if ($pctUnverifiable -gt 0) { $segments += "#8b949e ${runningPct}% $($runningPct + $pctUnverifiable)%" $runningPct += $pctUnverifiable } if ($pctPending -gt 0) { $segments += "#6e7681 ${runningPct}% 100%" } $conicGradient = if ($segments.Count -gt 0) { $segments -join ', ' } else { '#6e7681 0% 100%' } $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', Helvetica, Arial, sans-serif; background: #0d1117; color: #e6edf3; line-height: 1.6; padding: 2rem; } .container { max-width: 1200px; margin: 0 auto; } h1 { color: #a371f7; font-size: 1.8rem; border-bottom: 2px solid #a371f7; padding-bottom: 0.5rem; margin-bottom: 0.5rem; } .subtitle { color: #8b949e; margin-bottom: 2rem; font-size: 0.95rem; } h2 { color: #a371f7; font-size: 1.3rem; margin: 2rem 0 1rem 0; padding-bottom: 0.3rem; border-bottom: 1px solid #30363d; } h3 { color: #c9d1d9; font-size: 1.1rem; margin: 1rem 0 0.5rem 0; } h4 { color: #c9d1d9; font-size: 1rem; margin-bottom: 0.3rem; } /* Summary Cards */ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 2rem; } .summary-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.2rem; text-align: center; } .summary-card .number { font-size: 2.2rem; font-weight: bold; display: block; } .summary-card .label { font-size: 0.85rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; } .card-total .number { color: #a371f7; } .card-verified .number { color: #3fb950; } .card-drift .number { color: #f85149; } .card-unreachable .number { color: #d29922; } .card-unverifiable .number { color: #8b949e; } /* Donut Chart */ .chart-section { display: flex; align-items: center; justify-content: center; gap: 2rem; margin: 2rem 0; } .donut { width: 180px; height: 180px; border-radius: 50%; background: conic-gradient($conicGradient); position: relative; } .donut::after { content: ''; position: absolute; top: 35px; left: 35px; width: 110px; height: 110px; border-radius: 50%; background: #0d1117; } .donut-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 1; text-align: center; } .donut-center .pct { font-size: 1.6rem; font-weight: bold; color: #a371f7; } .donut-center .pct-label { font-size: 0.7rem; color: #8b949e; } .legend { list-style: none; } .legend li { margin: 0.4rem 0; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; } .legend-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; } /* Tables */ table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; } th { background: #161b22; color: #a371f7; text-align: left; padding: 0.7rem 0.8rem; border-bottom: 2px solid #30363d; font-weight: 600; } td { padding: 0.6rem 0.8rem; border-bottom: 1px solid #21262d; } tr:hover { background: #161b22; } .drift-expected { color: #f85149; text-decoration: line-through; } .drift-actual { color: #3fb950; font-weight: 600; } .verified-check { color: #3fb950; font-size: 1.2rem; text-align: center; } .source-doc { color: #8b949e; font-size: 0.8rem; } /* Badges */ .category-badge { background: #30363d; color: #a371f7; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } .status-badge { padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; } .status-verified { background: #0f2d1a; color: #3fb950; } .status-drift { background: #3d1214; color: #f85149; } .status-unreachable { background: #3d2e00; color: #d29922; } .status-unverifiable { background: #21262d; color: #8b949e; } /* Source & Category cards */ .card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; } .source-card, .category-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; } .text-green { color: #3fb950; } .text-red { color: #f85149; } /* Mini progress bars */ .mini-bar { height: 6px; background: #21262d; border-radius: 3px; display: flex; overflow: hidden; margin: 0.5rem 0; } .mini-bar-verified { background: #3fb950; } .mini-bar-drift { background: #f85149; } .category-card p, .source-card p { font-size: 0.85rem; color: #8b949e; } /* Section toggle (collapsible) */ details { margin: 1rem 0; } summary { cursor: pointer; color: #a371f7; font-size: 1.1rem; font-weight: 600; padding: 0.5rem 0; } summary:hover { color: #c9a0ff; } /* Footer */ .footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #30363d; text-align: center; color: #484f58; font-size: 0.8rem; } /* No data message */ .no-data { color: #8b949e; font-style: italic; padding: 1rem; text-align: center; } </style> </head> <body> <div class="container"> <h1>$Title</h1> <p class="subtitle">Generated: $reportDate | Source documents: $(($meta.source_documents | Measure-Object).Count)</p> <!-- Executive Summary --> <h2>Executive Summary</h2> <div class="summary-grid"> <div class="summary-card card-total"> <span class="number">$total</span> <span class="label">Total Claims</span> </div> <div class="summary-card card-verified"> <span class="number">$($verified.Count)</span> <span class="label">Verified</span> </div> <div class="summary-card card-drift"> <span class="number">$($drift.Count)</span> <span class="label">Drift Detected</span> </div> <div class="summary-card card-unreachable"> <span class="number">$($unreachable.Count)</span> <span class="label">Unreachable</span> </div> <div class="summary-card card-unverifiable"> <span class="number">$($unverifiable.Count)</span> <span class="label">Unverifiable</span> </div> </div> <!-- Donut Chart --> <div class="chart-section"> <div class="donut"> <div class="donut-center"> <span class="pct">$pctVerified%</span> <br><span class="pct-label">ACCURATE</span> </div> </div> <ul class="legend"> <li><span class="legend-dot" style="background:#3fb950"></span> Verified ($($verified.Count))</li> <li><span class="legend-dot" style="background:#f85149"></span> Drift ($($drift.Count))</li> <li><span class="legend-dot" style="background:#d29922"></span> Unreachable ($($unreachable.Count))</li> <li><span class="legend-dot" style="background:#8b949e"></span> Unverifiable ($($unverifiable.Count))</li> $(if ($pending.Count -gt 0) { "<li><span class='legend-dot' style='background:#6e7681'></span> Pending ($($pending.Count))</li>" }) </ul> </div> <!-- Categories --> <h2>By Category</h2> <div class="card-grid"> $categoryHtml </div> <!-- Drift Details --> <h2>Drift Detected</h2> $(if ($drift.Count -gt 0) { @" <p style="color:#8b949e;margin-bottom:1rem;">The following $($drift.Count) claims no longer match reality:</p> <table> <thead> <tr> <th>Category</th> <th>Subject</th> <th>Claim</th> <th>Document Says</th> <th>Reality</th> <th>Source</th> </tr> </thead> <tbody> $driftRowsHtml </tbody> </table> "@ } else { '<p class="no-data">No drift detected. All verified claims match the documentation.</p>' }) <!-- Verified Facts --> <details> <summary>Verified Facts ($($verified.Count) claims confirmed accurate)</summary> $(if ($verified.Count -gt 0) { @" <table> <thead> <tr> <th>Category</th> <th>Subject</th> <th>Claim</th> <th>Value</th> <th>Status</th> <th>Source</th> </tr> </thead> <tbody> $verifiedRowsHtml </tbody> </table> "@ } else { '<p class="no-data">No facts have been verified yet.</p>' }) </details> <!-- Unreachable --> $(if ($unreachable.Count -gt 0) { @" <details> <summary>Unreachable ($($unreachable.Count) claims could not be checked)</summary> <table> <thead> <tr> <th>Category</th> <th>Subject</th> <th>Claim</th> <th>Expected</th> <th>Reason</th> <th>Source</th> </tr> </thead> <tbody> $unreachableRowsHtml </tbody> </table> </details> "@ }) <!-- Source Documents --> <h2>Source Documents</h2> <div class="card-grid"> $sourceDocsHtml </div> <div class="footer"> Generated by <strong>Infra-LivingDoc</strong> — Living Documentation for IT Infrastructure </div> </div> </body> </html> "@ # Add System.Web for HtmlEncode if not loaded Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue if ($OutputPath) { $parentDir = Split-Path $OutputPath -Parent if ($parentDir -and -not (Test-Path $parentDir)) { New-Item -ItemType Directory -Path $parentDir -Force | Out-Null } $html | Out-File -FilePath $OutputPath -Encoding UTF8 -Force Write-Verbose "Dashboard saved to: $OutputPath" return Get-Item $OutputPath } else { return $html } } |