Private/New-HtmlDashboard.ps1
|
function New-HtmlDashboard { <# .SYNOPSIS Generates a dark-themed HTML dashboard report from change data. .DESCRIPTION Creates a self-contained HTML file with a dark theme and emerald green (#3fb950) accent color. Includes a timeline view, category tabs, summary cards, severity badges, and filter/sort capabilities. .PARAMETER Changes Array of change objects to render in the report. .PARAMETER OutputPath Full file path for the HTML report. .PARAMETER HoursBack Number of hours the audit covered. .PARAMETER AuditStartTime The datetime the audit started (for report header). #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]]$Changes, [Parameter(Mandatory = $true)] [string]$OutputPath, [Parameter()] [int]$HoursBack = 24, [Parameter()] [datetime]$AuditStartTime = (Get-Date) ) # Compute summary counts $TotalChanges = @($Changes).Count $ADCount = @($Changes | Where-Object { $_.Category -eq 'ActiveDirectory' }).Count $GPOCount = @($Changes | Where-Object { $_.Category -eq 'GroupPolicy' }).Count $DNSCount = @($Changes | Where-Object { $_.Category -eq 'DNS' }).Count $ServerCount = @($Changes | Where-Object { $_.Category -eq 'ServerConfig' }).Count $CriticalCount = @($Changes | Where-Object { $_.Severity -eq 'Critical' }).Count $HighCount = @($Changes | Where-Object { $_.Severity -eq 'High' }).Count $MediumCount = @($Changes | Where-Object { $_.Severity -eq 'Medium' }).Count $LowCount = @($Changes | Where-Object { $_.Severity -eq 'Low' }).Count $ReportTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $PeriodStart = (Get-Date).AddHours(-$HoursBack).ToString('yyyy-MM-dd HH:mm') $PeriodEnd = (Get-Date).ToString('yyyy-MM-dd HH:mm') # Build table rows for each category function ConvertTo-HtmlRow { param([object]$Change) $SeverityClass = switch ($Change.Severity) { 'Critical' { 'severity-critical' } 'High' { 'severity-high' } 'Medium' { 'severity-medium' } 'Low' { 'severity-low' } default { 'severity-low' } } $TimeStr = if ($Change.ChangeTime -is [datetime]) { $Change.ChangeTime.ToString('yyyy-MM-dd HH:mm:ss') } else { $Change.ChangeTime.ToString() } $EscDetail = [System.Web.HttpUtility]::HtmlEncode($Change.Detail) $EscObject = [System.Web.HttpUtility]::HtmlEncode($Change.ObjectName) $EscChangedBy = [System.Web.HttpUtility]::HtmlEncode($Change.ChangedBy) $EscOldVal = [System.Web.HttpUtility]::HtmlEncode($Change.OldValue) $EscNewVal = [System.Web.HttpUtility]::HtmlEncode($Change.NewValue) $EscSource = [System.Web.HttpUtility]::HtmlEncode($Change.Source) return @" <tr class="change-row" data-category="$($Change.Category)" data-severity="$($Change.Severity)" data-time="$TimeStr"> <td class="time-cell">$TimeStr</td> <td><span class="badge $SeverityClass">$($Change.Severity)</span></td> <td>$($Change.ChangeType)</td> <td class="object-cell" title="$EscObject">$EscObject</td> <td>$($Change.ObjectType)</td> <td class="changedby-cell">$EscChangedBy</td> <td class="detail-cell" title="$EscDetail">$EscDetail</td> <td class="source-cell" title="$EscSource">$EscSource</td> </tr> "@ } # Add System.Web assembly for HtmlEncode Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue # Generate rows by category $ADRows = ($Changes | Where-Object { $_.Category -eq 'ActiveDirectory' } | ForEach-Object { ConvertTo-HtmlRow -Change $_ }) -join "`n" $GPORows = ($Changes | Where-Object { $_.Category -eq 'GroupPolicy' } | ForEach-Object { ConvertTo-HtmlRow -Change $_ }) -join "`n" $DNSRows = ($Changes | Where-Object { $_.Category -eq 'DNS' } | ForEach-Object { ConvertTo-HtmlRow -Change $_ }) -join "`n" $ServerRows = ($Changes | Where-Object { $_.Category -eq 'ServerConfig' } | ForEach-Object { ConvertTo-HtmlRow -Change $_ }) -join "`n" $AllRows = ($Changes | ForEach-Object { ConvertTo-HtmlRow -Change $_ }) -join "`n" # Generate timeline data (hourly buckets) $TimelineData = @{} foreach ($Change in $Changes) { if ($Change.ChangeTime -is [datetime]) { $Bucket = $Change.ChangeTime.ToString('yyyy-MM-dd HH:00') if (-not $TimelineData.ContainsKey($Bucket)) { $TimelineData[$Bucket] = @{ AD = 0; GPO = 0; DNS = 0; Server = 0 } } switch ($Change.Category) { 'ActiveDirectory' { $TimelineData[$Bucket].AD++ } 'GroupPolicy' { $TimelineData[$Bucket].GPO++ } 'DNS' { $TimelineData[$Bucket].DNS++ } 'ServerConfig' { $TimelineData[$Bucket].Server++ } } } } $TimelineBarsHtml = '' if ($TimelineData.Count -gt 0) { $MaxCount = ($TimelineData.Values | ForEach-Object { $_.AD + $_.GPO + $_.DNS + $_.Server } | Measure-Object -Maximum).Maximum if ($MaxCount -eq 0) { $MaxCount = 1 } $SortedBuckets = $TimelineData.Keys | Sort-Object foreach ($Bucket in $SortedBuckets) { $Data = $TimelineData[$Bucket] $Total = $Data.AD + $Data.GPO + $Data.DNS + $Data.Server $HeightPct = [Math]::Max(5, [Math]::Round(($Total / $MaxCount) * 100)) $Label = ($Bucket -split ' ')[1] $ADPct = if ($Total -gt 0) { [Math]::Round(($Data.AD / $Total) * 100) } else { 0 } $GPOPct = if ($Total -gt 0) { [Math]::Round(($Data.GPO / $Total) * 100) } else { 0 } $DNSPct = if ($Total -gt 0) { [Math]::Round(($Data.DNS / $Total) * 100) } else { 0 } $ServerPct = if ($Total -gt 0) { 100 - $ADPct - $GPOPct - $DNSPct } else { 0 } $TimelineBarsHtml += @" <div class="timeline-bar-wrapper" title="$Bucket - $Total change(s)"> <div class="timeline-bar" style="height: ${HeightPct}%;"> <div class="bar-segment bar-ad" style="height: ${ADPct}%;"></div> <div class="bar-segment bar-gpo" style="height: ${GPOPct}%;"></div> <div class="bar-segment bar-dns" style="height: ${DNSPct}%;"></div> <div class="bar-segment bar-server" style="height: ${ServerPct}%;"></div> </div> <div class="timeline-label">$Label</div> <div class="timeline-count">$Total</div> </div> "@ } } else { $TimelineBarsHtml = '<div class="no-data">No timeline data available</div>' } # Build the complete HTML document $Html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Infrastructure Change Report - $ReportTime</title> <style> :root { --accent: #3fb950; --accent-dim: #2ea043; --bg-primary: #0d1117; --bg-secondary: #161b22; --bg-tertiary: #21262d; --bg-card: #1c2128; --border: #30363d; --text-primary: #e6edf3; --text-secondary: #8b949e; --text-muted: #6e7681; --severity-critical: #f85149; --severity-high: #d29922; --severity-medium: #e3b341; --severity-low: #8b949e; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg-primary); color: var(--text-primary); line-height: 1.5; } .container { max-width: 1400px; margin: 0 auto; padding: 20px; } /* Header */ .header { border-bottom: 1px solid var(--border); padding-bottom: 20px; margin-bottom: 24px; } .header h1 { font-size: 24px; font-weight: 600; color: var(--accent); margin-bottom: 4px; } .header .subtitle { color: var(--text-secondary); font-size: 14px; } .header .meta { display: flex; gap: 24px; margin-top: 12px; font-size: 13px; color: var(--text-muted); } .header .meta span { display: flex; align-items: center; gap: 4px; } /* Summary Cards */ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; } .summary-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; } .summary-card .count { font-size: 32px; font-weight: 700; color: var(--accent); } .summary-card .label { font-size: 13px; color: var(--text-secondary); margin-top: 4px; } .summary-card.card-critical .count { color: var(--severity-critical); } .summary-card.card-high .count { color: var(--severity-high); } /* Timeline */ .timeline-section { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 24px; } .timeline-section h2 { font-size: 16px; margin-bottom: 16px; color: var(--text-primary); } .timeline-container { display: flex; align-items: flex-end; gap: 4px; height: 120px; padding: 0 8px; overflow-x: auto; } .timeline-bar-wrapper { display: flex; flex-direction: column; align-items: center; flex: 1; min-width: 28px; height: 100%; justify-content: flex-end; } .timeline-bar { width: 100%; max-width: 40px; border-radius: 3px 3px 0 0; display: flex; flex-direction: column; justify-content: flex-end; overflow: hidden; cursor: pointer; transition: opacity 0.2s; } .timeline-bar:hover { opacity: 0.8; } .bar-segment { width: 100%; min-height: 0; } .bar-ad { background: var(--accent); } .bar-gpo { background: #58a6ff; } .bar-dns { background: #d2a8ff; } .bar-server { background: #f0883e; } .timeline-label { font-size: 10px; color: var(--text-muted); margin-top: 4px; } .timeline-count { font-size: 10px; color: var(--text-secondary); } .timeline-legend { display: flex; gap: 16px; margin-top: 12px; justify-content: center; } .timeline-legend span { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); } .legend-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } /* Tabs */ .tabs { display: flex; gap: 2px; margin-bottom: 0; border-bottom: 1px solid var(--border); } .tab { padding: 10px 20px; background: var(--bg-tertiary); border: 1px solid var(--border); border-bottom: none; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 14px; color: var(--text-secondary); transition: all 0.2s; } .tab:hover { color: var(--text-primary); background: var(--bg-secondary); } .tab.active { color: var(--accent); background: var(--bg-secondary); border-bottom: 2px solid var(--accent); font-weight: 600; } .tab .tab-count { background: var(--bg-tertiary); color: var(--text-secondary); font-size: 11px; padding: 2px 6px; border-radius: 10px; margin-left: 6px; } .tab.active .tab-count { background: var(--accent-dim); color: white; } /* Tab Content */ .tab-content { display: none; } .tab-content.active { display: block; } /* Controls */ .controls { display: flex; gap: 12px; align-items: center; padding: 12px 0; flex-wrap: wrap; } .controls select, .controls input { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-primary); padding: 6px 12px; border-radius: 6px; font-size: 13px; } .controls select:focus, .controls input:focus { outline: none; border-color: var(--accent); } .controls label { font-size: 13px; color: var(--text-secondary); } /* Table */ .changes-table-wrapper { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 0 8px 8px 8px; overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 13px; } th { background: var(--bg-tertiary); color: var(--text-secondary); padding: 10px 12px; text-align: left; font-weight: 600; border-bottom: 1px solid var(--border); white-space: nowrap; cursor: pointer; user-select: none; } th:hover { color: var(--accent); } td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: top; } tr:hover td { background: rgba(63, 185, 80, 0.05); } .time-cell { white-space: nowrap; color: var(--text-secondary); font-family: monospace; font-size: 12px; } .object-cell { font-weight: 600; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .changedby-cell { color: var(--accent); max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .detail-cell { max-width: 350px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); } .source-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-size: 12px; } /* Severity Badges */ .badge { padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } .severity-critical { background: rgba(248, 81, 73, 0.2); color: var(--severity-critical); border: 1px solid rgba(248, 81, 73, 0.4); } .severity-high { background: rgba(210, 153, 34, 0.2); color: var(--severity-high); border: 1px solid rgba(210, 153, 34, 0.4); } .severity-medium { background: rgba(227, 179, 65, 0.15); color: var(--severity-medium); border: 1px solid rgba(227, 179, 65, 0.3); } .severity-low { background: rgba(139, 148, 158, 0.15); color: var(--severity-low); border: 1px solid rgba(139, 148, 158, 0.3); } .no-data { text-align: center; padding: 40px; color: var(--text-muted); font-size: 14px; } /* Footer */ .footer { text-align: center; padding: 20px; margin-top: 24px; color: var(--text-muted); font-size: 12px; border-top: 1px solid var(--border); } @media (max-width: 768px) { .summary-grid { grid-template-columns: repeat(2, 1fr); } .tabs { flex-wrap: wrap; } } </style> </head> <body> <div class="container"> <!-- Header --> <div class="header"> <h1>Infrastructure Change Report</h1> <div class="subtitle">Automated change detection across Active Directory, Group Policy, DNS, and Server Configuration</div> <div class="meta"> <span>Generated: $ReportTime</span> <span>Period: $PeriodStart to $PeriodEnd ($HoursBack hours)</span> <span>By: $env:USERDOMAIN\$env:USERNAME</span> </div> </div> <!-- Summary Cards --> <div class="summary-grid"> <div class="summary-card"> <div class="count">$TotalChanges</div> <div class="label">Total Changes</div> </div> <div class="summary-card"> <div class="count">$ADCount</div> <div class="label">Active Directory</div> </div> <div class="summary-card"> <div class="count">$GPOCount</div> <div class="label">Group Policy</div> </div> <div class="summary-card"> <div class="count">$DNSCount</div> <div class="label">DNS</div> </div> <div class="summary-card"> <div class="count">$ServerCount</div> <div class="label">Server Config</div> </div> <div class="summary-card card-critical"> <div class="count">$CriticalCount</div> <div class="label">Critical</div> </div> <div class="summary-card card-high"> <div class="count">$HighCount</div> <div class="label">High Severity</div> </div> </div> <!-- Timeline --> <div class="timeline-section"> <h2>Change Timeline</h2> <div class="timeline-container"> $TimelineBarsHtml </div> <div class="timeline-legend"> <span><span class="legend-dot" style="background:var(--accent);"></span> AD</span> <span><span class="legend-dot" style="background:#58a6ff;"></span> GPO</span> <span><span class="legend-dot" style="background:#d2a8ff;"></span> DNS</span> <span><span class="legend-dot" style="background:#f0883e;"></span> Server</span> </div> </div> <!-- Category Tabs --> <div class="tabs"> <div class="tab active" onclick="switchTab('all')">All Changes<span class="tab-count">$TotalChanges</span></div> <div class="tab" onclick="switchTab('ad')">AD Changes<span class="tab-count">$ADCount</span></div> <div class="tab" onclick="switchTab('gpo')">GPO Changes<span class="tab-count">$GPOCount</span></div> <div class="tab" onclick="switchTab('dns')">DNS Changes<span class="tab-count">$DNSCount</span></div> <div class="tab" onclick="switchTab('server')">Server Config<span class="tab-count">$ServerCount</span></div> </div> <!-- Controls --> <div class="controls"> <label for="severityFilter">Severity:</label> <select id="severityFilter" onchange="applyFilters()"> <option value="all">All</option> <option value="Critical">Critical</option> <option value="High">High</option> <option value="Medium">Medium</option> <option value="Low">Low</option> </select> <label for="sortBy">Sort:</label> <select id="sortBy" onchange="applySort()"> <option value="time-desc">Newest First</option> <option value="time-asc">Oldest First</option> <option value="severity">Severity</option> </select> <label for="searchBox">Search:</label> <input type="text" id="searchBox" placeholder="Filter changes..." oninput="applyFilters()"> </div> <!-- Tab Content: All --> <div id="tab-all" class="tab-content active"> <div class="changes-table-wrapper"> <table> <thead> <tr> <th>Time</th><th>Severity</th><th>Action</th><th>Object</th> <th>Type</th><th>Changed By</th><th>Detail</th><th>Source</th> </tr> </thead> <tbody id="tbody-all"> $AllRows </tbody> </table> $(if ($TotalChanges -eq 0) { '<div class="no-data">No changes detected in the specified timeframe.</div>' }) </div> </div> <!-- Tab Content: AD --> <div id="tab-ad" class="tab-content"> <div class="changes-table-wrapper"> <table> <thead> <tr> <th>Time</th><th>Severity</th><th>Action</th><th>Object</th> <th>Type</th><th>Changed By</th><th>Detail</th><th>Source</th> </tr> </thead> <tbody id="tbody-ad"> $ADRows </tbody> </table> $(if ($ADCount -eq 0) { '<div class="no-data">No Active Directory changes detected.</div>' }) </div> </div> <!-- Tab Content: GPO --> <div id="tab-gpo" class="tab-content"> <div class="changes-table-wrapper"> <table> <thead> <tr> <th>Time</th><th>Severity</th><th>Action</th><th>Object</th> <th>Type</th><th>Changed By</th><th>Detail</th><th>Source</th> </tr> </thead> <tbody id="tbody-gpo"> $GPORows </tbody> </table> $(if ($GPOCount -eq 0) { '<div class="no-data">No Group Policy changes detected.</div>' }) </div> </div> <!-- Tab Content: DNS --> <div id="tab-dns" class="tab-content"> <div class="changes-table-wrapper"> <table> <thead> <tr> <th>Time</th><th>Severity</th><th>Action</th><th>Object</th> <th>Type</th><th>Changed By</th><th>Detail</th><th>Source</th> </tr> </thead> <tbody id="tbody-dns"> $DNSRows </tbody> </table> $(if ($DNSCount -eq 0) { '<div class="no-data">No DNS changes detected.</div>' }) </div> </div> <!-- Tab Content: Server --> <div id="tab-server" class="tab-content"> <div class="changes-table-wrapper"> <table> <thead> <tr> <th>Time</th><th>Severity</th><th>Action</th><th>Object</th> <th>Type</th><th>Changed By</th><th>Detail</th><th>Source</th> </tr> </thead> <tbody id="tbody-server"> $ServerRows </tbody> </table> $(if ($ServerCount -eq 0) { '<div class="no-data">No server configuration changes detected.</div>' }) </div> </div> <div class="footer"> Infra-ChangeTracker v1.0.0 · Generated by $env:USERDOMAIN\$env:USERNAME · $ReportTime </div> </div> <script> function switchTab(tab) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); event.currentTarget.classList.add('active'); document.getElementById('tab-' + tab).classList.add('active'); } function applyFilters() { const severity = document.getElementById('severityFilter').value; const search = document.getElementById('searchBox').value.toLowerCase(); document.querySelectorAll('.change-row').forEach(row => { const matchSeverity = severity === 'all' || row.dataset.severity === severity; const matchSearch = !search || row.textContent.toLowerCase().includes(search); row.style.display = (matchSeverity && matchSearch) ? '' : 'none'; }); } function applySort() { const sortBy = document.getElementById('sortBy').value; const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 }; document.querySelectorAll('tbody').forEach(tbody => { const rows = Array.from(tbody.querySelectorAll('.change-row')); rows.sort((a, b) => { if (sortBy === 'time-desc') return b.dataset.time.localeCompare(a.dataset.time); if (sortBy === 'time-asc') return a.dataset.time.localeCompare(b.dataset.time); if (sortBy === 'severity') return (severityOrder[a.dataset.severity] || 9) - (severityOrder[b.dataset.severity] || 9); return 0; }); rows.forEach(row => tbody.appendChild(row)); }); } </script> </body> </html> "@ # Write the HTML file with UTF-8 BOM encoding $OutputDir = Split-Path -Path $OutputPath -Parent if ($OutputDir -and -not (Test-Path -Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null } [System.IO.File]::WriteAllText( [System.IO.Path]::GetFullPath($OutputPath), $Html, [System.Text.UTF8Encoding]::new($true) ) Write-Verbose "HTML dashboard saved to $OutputPath" return $OutputPath } |