Modules/Private/Export-S2DHtmlReport.ps1
|
# HTML report exporter — self-contained single-file dashboard with Chart.js function Export-S2DHtmlReport { param( [Parameter(Mandatory)] [S2DClusterData] $ClusterData, [Parameter(Mandatory)] [string] $OutputPath, [string] $Author = '', [string] $Company = '' ) $generatedAt = Get-Date -Format 'yyyy-MM-dd HH:mm' $cn = $ClusterData.ClusterName $nc = $ClusterData.NodeCount $pool = $ClusterData.StoragePool $wf = $ClusterData.CapacityWaterfall $hc = @($ClusterData.HealthChecks) $oh = $ClusterData.OverallHealth $vols = @($ClusterData.Volumes) $disks = @($ClusterData.PhysicalDisks) $cache = $ClusterData.CacheTier $overallBg = switch ($oh) { 'Healthy'{'#dff6dd'} 'Warning'{'#fff4ce'} 'Critical'{'#fde7e9'} default{'#f3f2f1'} } $overallFg = switch ($oh) { 'Healthy'{'#107c10'} 'Warning'{'#d47a00'} 'Critical'{'#d13438'} default{'#323130'} } # ── Waterfall chart data ────────────────────────────────────────────────── $wfLabels = '' $wfValues = '' if ($wf) { $wfLabels = ($wf.Stages | ForEach-Object { "'Stage $($_.Stage): $($_.Name)'" }) -join ',' $wfValues = ($wf.Stages | ForEach-Object { if ($_.Size) { [math]::Round($_.Size.TiB, 2) } else { 0 } }) -join ',' } # ── Disk inventory table rows ───────────────────────────────────────────── $diskRows = ($disks | ForEach-Object { $hw = if ($_.WearPercentage -gt 80) { ' style="color:#d13438"' } else { '' } $hs = if ($_.HealthStatus -eq 'Healthy') { '<span class="badge ok">Healthy</span>' } else { "<span class='badge fail'>$($_.HealthStatus)</span>" } "<tr><td>$($_.NodeName)</td><td>$($_.FriendlyName)</td><td>$($_.MediaType)</td><td>$($_.Role)</td><td>$(if($_.Size){"$($_.Size.TiB) TiB ($($_.Size.TB) TB)"}else{'N/A'})</td><td$hw>$(if($null -ne $_.WearPercentage){"$($_.WearPercentage)%"}else{'N/A'})</td><td>$hs</td></tr>" }) -join "`n" # ── Volume table rows ───────────────────────────────────────────────────── $volRows = ($vols | ForEach-Object { $infraTag = if ($_.IsInfrastructureVolume) { ' <span class="badge info">Infra</span>' } else { '' } $hs = if ($_.HealthStatus -eq 'Healthy') { '<span class="badge ok">Healthy</span>' } else { "<span class='badge fail'>$($_.HealthStatus)</span>" } "<tr><td>$($_.FriendlyName)$infraTag</td><td>$($_.ResiliencySettingName) ($($_.NumberOfDataCopies) copies)</td><td>$(if($_.Size){"$($_.Size.TiB) TiB"}else{'N/A'})</td><td>$(if($_.FootprintOnPool){"$($_.FootprintOnPool.TiB) TiB"}else{'N/A'})</td><td>$($_.EfficiencyPercent)%</td><td>$($_.ProvisioningType)</td><td>$hs</td></tr>" }) -join "`n" # ── Health check cards ──────────────────────────────────────────────────── $hcCards = ($hc | ForEach-Object { $cls = switch ($_.Status) { 'Pass'{'hc-pass'} 'Warn'{'hc-warn'} 'Fail'{'hc-fail'} default{'hc-info'} } $icon = switch ($_.Status) { 'Pass'{'✔'} 'Warn'{'⚠'} 'Fail'{'✖'} default{'ℹ'} } "<div class='hc-card $cls'><span class='hc-icon'>$icon</span><div class='hc-body'><strong>$($_.CheckName)</strong> <em>[$($_.Severity)]</em><p>$([System.Net.WebUtility]::HtmlEncode($_.Details))</p>$(if($_.Status -ne 'Pass'){"<p class='remediation'><strong>Remediation:</strong> $([System.Net.WebUtility]::HtmlEncode($_.Remediation))</p>"})</div></div>" }) -join "`n" # ── Cache tier summary ──────────────────────────────────────────────────── $cacheSummary = if ($cache) { $allFlashTag = if ($cache.IsAllFlash) { '<span class="badge info">All-Flash</span>' } else { '' } "<p><strong>Cache Mode:</strong> $($cache.CacheMode) $allFlashTag <strong>State:</strong> $($cache.CacheState) <strong>Disks:</strong> $($cache.CacheDiskCount)</p>" } else { '<p>Cache data not available.</p>' } $poolSummary = if ($pool) { "<p><strong>Pool:</strong> $($pool.FriendlyName) <strong>Health:</strong> $($pool.HealthStatus) <strong>Total:</strong> $($pool.TotalSize.TiB) TiB <strong>Allocated:</strong> $($pool.AllocatedSize.TiB) TiB <strong>Free:</strong> $($pool.RemainingSize.TiB) TiB <strong>Overcommit:</strong> $($pool.OvercommitRatio)x</p>" } else { '<p>Pool data not available.</p>' } $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>S2D Cartographer — $cn</title> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <style> :root{--blue:#0078d4;--teal:#008272;--green:#107c10;--amber:#e8a218;--red:#d13438;--bg:#faf9f8;--card:#ffffff;--border:#edebe9;--text:#201f1e;--muted:#605e5c} *{box-sizing:border-box;margin:0;padding:0} body{font-family:'Segoe UI',Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px} header{background:var(--blue);color:white;padding:20px 32px;display:flex;justify-content:space-between;align-items:center} header h1{font-size:22px;font-weight:600} header .meta{font-size:12px;opacity:.85;text-align:right} .container{max-width:1200px;margin:0 auto;padding:24px} .section{background:var(--card);border:1px solid var(--border);border-radius:6px;margin-bottom:20px;padding:20px} .section h2{font-size:16px;font-weight:600;margin-bottom:12px;padding-bottom:8px;border-bottom:2px solid var(--blue);color:var(--blue)} .overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:16px} .kpi{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:14px;text-align:center} .kpi .val{font-size:22px;font-weight:700;color:var(--blue)} .kpi .lbl{font-size:11px;color:var(--muted);margin-top:4px} .health-banner{border-radius:6px;padding:12px 20px;margin-bottom:16px;font-weight:600;font-size:15px;background:$overallBg;color:$overallFg} table{width:100%;border-collapse:collapse;font-size:13px} th{background:#f3f2f1;text-align:left;padding:8px 10px;font-weight:600;border-bottom:2px solid var(--border)} td{padding:7px 10px;border-bottom:1px solid var(--border)} tr:hover{background:#f3f2f1} .badge{display:inline-block;border-radius:4px;padding:2px 7px;font-size:11px;font-weight:600} .badge.ok{background:#dff6dd;color:#107c10}.badge.fail{background:#fde7e9;color:#d13438}.badge.info{background:#eff6fc;color:#0078d4} .hc-card{display:flex;align-items:flex-start;gap:12px;border-radius:6px;padding:12px 16px;margin-bottom:8px;border-left:4px solid} .hc-pass{background:#dff6dd;border-color:#107c10}.hc-warn{background:#fff4ce;border-color:#e8a218}.hc-fail{background:#fde7e9;border-color:#d13438}.hc-info{background:#eff6fc;border-color:#0078d4} .hc-icon{font-size:20px;min-width:24px}.hc-body strong{font-size:13px}.hc-body p{font-size:12px;color:var(--muted);margin-top:4px}.remediation{color:#323130 !important;font-style:italic} .toggle-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;font-size:13px} .toggle-btn{background:var(--blue);color:white;border:none;border-radius:4px;padding:5px 14px;cursor:pointer;font-size:12px} .chart-wrap{position:relative;height:300px} .tib-tb-table{font-size:13px} .tib-tb-table th,.tib-tb-table td{padding:6px 14px} @media print{.toggle-row{display:none}} </style> </head> <body> <header> <div> <h1>S2D Cartographer Report</h1> <div style="font-size:13px;opacity:.9">$cn | $nc nodes</div> </div> <div class="meta"> Generated: $generatedAt<br> $(if($Author){"By: $Author<br>"})$(if($Company){"$Company"}) </div> </header> <div class="container"> <div class="section"> <h2>Executive Summary</h2> <div class="health-banner">Overall Health: $oh</div> <div class="overview-grid"> <div class="kpi"><div class="val">$nc</div><div class="lbl">Nodes</div></div> <div class="kpi"><div class="val">$(if($wf){"$($wf.RawCapacity.TiB) TiB"}else{'N/A'})</div><div class="lbl">Raw Capacity</div></div> <div class="kpi"><div class="val">$(if($wf){"$($wf.UsableCapacity.TiB) TiB"}else{'N/A'})</div><div class="lbl">Usable Capacity</div></div> <div class="kpi"><div class="val">$(if($pool){"$($pool.RemainingSize.TiB) TiB"}else{'N/A'})</div><div class="lbl">Pool Free</div></div> <div class="kpi"><div class="val">$($disks.Count)</div><div class="lbl">Physical Disks</div></div> <div class="kpi"><div class="val">$(@($vols | Where-Object { -not $_.IsInfrastructureVolume }).Count)</div><div class="lbl">Workload Volumes</div></div> <div class="kpi"><div class="val">$(if($wf){"$($wf.ReserveStatus)"}else{'N/A'})</div><div class="lbl">Reserve Status</div></div> <div class="kpi"><div class="val">$(if($wf){"$($wf.BlendedEfficiencyPercent)%"}else{'N/A'})</div><div class="lbl">Resiliency Efficiency</div></div> </div> $poolSummary $cacheSummary </div> <div class="section"> <h2>Capacity Waterfall</h2> <div class="toggle-row"> <span>Display unit:</span> <button class="toggle-btn" onclick="toggleUnit()">Toggle TiB / TB</button> <span id="unitLabel" style="font-weight:600">TiB</span> </div> <div class="chart-wrap"><canvas id="waterfallChart"></canvas></div> </div> <div class="section"> <h2>Physical Disk Inventory</h2> <table id="diskTable"> <thead><tr><th>Node</th><th>Model</th><th>Media</th><th>Role</th><th>Size</th><th>Wear %</th><th>Health</th></tr></thead> <tbody>$diskRows</tbody> </table> </div> <div class="section"> <h2>Volume Map</h2> <table> <thead><tr><th>Volume</th><th>Resiliency</th><th>Size</th><th>Pool Footprint</th><th>Efficiency</th><th>Provisioning</th><th>Health</th></tr></thead> <tbody>$volRows</tbody> </table> </div> <div class="section"> <h2>Health Checks</h2> $hcCards </div> <div class="section"> <h2>Understanding Storage Units — TiB vs TB</h2> <p style="margin-bottom:12px;color:var(--muted)">Hard drive manufacturers use decimal (1 TB = 1,000,000,000,000 bytes). Windows reports in binary (1 TiB = 1,099,511,627,776 bytes). This creates an apparent ~9% discrepancy — the data is all there, it's just expressed in different units.</p> <table class="tib-tb-table"> <thead><tr><th>Drive Label (TB)</th><th>Windows Reports (TiB)</th><th>Difference</th></tr></thead> <tbody> <tr><td>0.96 TB</td><td>0.873 TiB</td><td style="color:var(--red)">-9.3%</td></tr> <tr><td>1.92 TB</td><td>1.747 TiB</td><td style="color:var(--red)">-9.0%</td></tr> <tr><td>3.84 TB</td><td>3.492 TiB</td><td style="color:var(--red)">-9.1%</td></tr> <tr><td>7.68 TB</td><td>6.986 TiB</td><td style="color:var(--red)">-9.0%</td></tr> <tr><td>15.36 TB</td><td>13.97 TiB</td><td style="color:var(--red)">-9.1%</td></tr> </tbody> </table> </div> </div> <script> const tibValues = [$wfValues]; const tbValues = tibValues.map(v => Math.round(v * 1.0995 * 100) / 100); const labels = [$wfLabels]; let useTiB = true; const ctx = document.getElementById('waterfallChart').getContext('2d'); const chart = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Capacity (TiB)', data: tibValues, backgroundColor: ['#0078d4','#005a9e','#106ebe','#e8a218','#d47a00','#107c10','#0e6e0e','#054b05'], borderRadius: 4 }] }, options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => ctx.raw + (useTiB ? ' TiB' : ' TB') } } }, scales: { x: { beginAtZero: true, title: { display: true, text: 'TiB' } } } } }); function toggleUnit() { useTiB = !useTiB; document.getElementById('unitLabel').textContent = useTiB ? 'TiB' : 'TB'; chart.data.datasets[0].data = useTiB ? tibValues : tbValues; chart.data.datasets[0].label = useTiB ? 'Capacity (TiB)' : 'Capacity (TB)'; chart.options.scales.x.title.text = useTiB ? 'TiB' : 'TB'; chart.update(); } </script> </body> </html> "@ $dir = Split-Path $OutputPath -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $html | Set-Content -Path $OutputPath -Encoding UTF8 -Force Write-Verbose "HTML report written to $OutputPath" $OutputPath } |