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   = ''
    $wfDescRows = ''
    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 ','
        $wfDescRows = ($wf.Stages | ForEach-Object {
            $icon     = switch ($_.Status) {
                'Fail'    { '<span style="color:#d13438;font-size:15px">&#10006;</span>' }
                'Warn'    { '<span style="color:#e8a218;font-size:15px">&#9888;</span>' }
                default   { '<span style="color:#107c10;font-size:15px">&#10004;</span>' }
            }
            $deltaStr = if ($_.Delta -and [math]::Abs($_.Delta.TB) -gt 0) {
                "<span style='color:#d13438;font-size:11px;margin-left:6px'>$([math]::Round($_.Delta.TB,2)) TB</span>"
            } else { '' }
            $remaining = if ($_.Size) { "$([math]::Round($_.Size.TB,2)) TB" } else { '0 TB' }
            "<tr><td style='width:28px;text-align:center;padding:6px 4px'>$icon</td><td style='width:24px;font-weight:700;color:#0078d4;padding:6px 8px'>$($_.Stage)</td><td style='font-weight:600;padding:6px 8px;white-space:nowrap'>$($_.Name)$deltaStr</td><td style='color:#605e5c;padding:6px 8px;font-size:12px'>$($_.Description)</td><td style='text-align:right;font-weight:600;padding:6px 8px;white-space:nowrap'>$remaining</td></tr>"
        }) -join "`n"
    }

    # ── Pool breakdown bar data ───────────────────────────────────────────────
    $poolBarDatasets  = ''
    $poolTotalTB      = 0
    $reserveTB        = 0
    $phUsed           = 0
    $phFree           = 0
    $phReserveOk      = 0
    $phReserveEaten   = 0
    $phOvercommit     = 0
    $phAvailLine      = 0
    if ($pool -and $vols) {
        $poolTotalTB = $pool.TotalSize.TB
        $reserveTB   = if ($wf) { [math]::Round($wf.ReserveRecommended.TB, 2) } else { 0 }
        $volColors   = @('#0078d4','#005a9e','#106ebe','#005b70','#00b7c3','#006f94','#4ba3c7','#00546e')
        $ci          = 0
        $dsLines     = @()

        foreach ($v in $vols) {
            $ftb   = if ($v.FootprintOnPool) { [math]::Round($v.FootprintOnPool.TB, 2) } else { 0 }
            $label = if ($v.IsInfrastructureVolume) { "$($v.FriendlyName) (infra) $ftb TB" } else { "$($v.FriendlyName) $ftb TB" }
            $color = if ($v.IsInfrastructureVolume) { '#008272' } else { $volColors[$ci % $volColors.Count]; $ci++ }
            $dsLines += "{ label: '$label', data: [$ftb], backgroundColor: '$color', borderWidth: 0 }"
        }

        $totalFootprintTB = [math]::Round(($vols | ForEach-Object { if ($_.FootprintOnPool) { $_.FootprintOnPool.TB } else { 0 } } | Measure-Object -Sum).Sum, 2)
        $freeTB           = [math]::Round([math]::Max(0, $poolTotalTB - $totalFootprintTB), 2)
        $overcommitTB     = [math]::Round([math]::Max(0, $totalFootprintTB - $poolTotalTB), 2)

        if ($freeTB -gt 0) {
            $dsLines += "{ label: 'Free $freeTB TB', data: [$freeTB], backgroundColor: '#dff6dd', borderWidth: 0 }"
        }
        if ($overcommitTB -gt 0) {
            $dsLines += "{ label: 'Overcommit $overcommitTB TB', data: [$overcommitTB], backgroundColor: '#d13438', borderWidth: 0 }"
        }

        $poolBarDatasets = $dsLines -join ','

        # Pool health bar segments
        $availForVols      = [math]::Round([math]::Max(0, $poolTotalTB - $reserveTB), 2)
        $phUsed            = [math]::Round([math]::Min($totalFootprintTB, $availForVols), 2)
        $phFree            = [math]::Round([math]::Max(0, $availForVols - $totalFootprintTB), 2)
        $eatIntoReserve    = [math]::Round([math]::Max(0, $totalFootprintTB - $availForVols), 2)
        $phReserveOk       = [math]::Round([math]::Max(0, $reserveTB - $eatIntoReserve), 2)
        $phReserveEaten    = [math]::Round([math]::Min($reserveTB, $eatIntoReserve), 2)
        $phOvercommit      = [math]::Round([math]::Max(0, $totalFootprintTB - $poolTotalTB), 2)
        $phAvailLine       = $availForVols
    }

    # ── 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 &nbsp; <strong>State:</strong> $($cache.CacheState) &nbsp; <strong>Disks:</strong> $($cache.CacheDiskCount)</p>"
    } else { '<p>Cache data not available.</p>' }

    $poolSummary = if ($pool) {
        "<p><strong>Pool:</strong> $($pool.FriendlyName) &nbsp; <strong>Health:</strong> $($pool.HealthStatus) &nbsp; <strong>Total:</strong> $($pool.TotalSize.TiB) TiB &nbsp; <strong>Allocated:</strong> $($pool.AllocatedSize.TiB) TiB &nbsp; <strong>Free:</strong> $($pool.RemainingSize.TiB) TiB &nbsp; <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}
.kpi.critical{background:#fde7e9;border-color:#d13438}.kpi.critical .val{color:#d13438}.kpi.critical .lbl{color:#d13438}
.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 &nbsp;|&nbsp; $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$(if($wf -and $wf.ReserveStatus -eq 'Critical'){' critical'}else{''})"><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 Model</h2>
  <p style="margin-bottom:14px;font-size:12px;color:var(--muted)">Theoretical pipeline showing how raw storage should be accounted for under S2D best practices. Each stage represents a recommended deduction. See the Volume Map and Health Checks below for actual current state.</p>
  <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>
  <table style="margin-top:16px;font-size:13px;width:100%;border-collapse:collapse">
    <thead><tr style="background:#f3f2f1"><th style="padding:7px 8px;text-align:left;width:28px"></th><th style="padding:7px 8px;text-align:left;width:24px">#</th><th style="padding:7px 8px;text-align:left">Stage</th><th style="padding:7px 8px;text-align:left">What it represents</th><th style="padding:7px 8px;text-align:right">Remaining</th></tr></thead>
    <tbody>$wfDescRows</tbody>
  </table>
</div>

<div class="section">
  <h2>Pool Allocation Breakdown</h2>
  <p style="margin-bottom:12px;font-size:12px;color:var(--muted)">Single bar showing how the raw storage pool is carved up across volumes. The dashed amber line marks the recommended rebuild reserve boundary. Any bar extending past the pool total is overcommit (shown in red).</p>
  <div style="position:relative;height:180px"><canvas id="poolBreakdownChart"></canvas></div>
</div>

<div class="section">
  <h2>Storage Pool Health</h2>
  <p style="margin-bottom:16px;font-size:12px;color:var(--muted)">The bar represents the full pool. The amber zone on the right is the recommended rebuild reserve — S2D needs this space free to auto-repair after a drive failure. If workload volumes eat into that zone it turns red. Any portion beyond the pool total is overcommit.</p>
  <div style="position:relative;height:130px"><canvas id="poolHealthChart"></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();
}

// Pool breakdown bar
const poolTotalTB = $poolTotalTB;
const reserveTB = $reserveTB;
const reserveLine = {
  id: 'reserveLine',
  afterDraw(chart) {
    if (!poolTotalTB) return;
    const ctx = chart.ctx;
    const xScale = chart.scales.x;
    // Pool boundary line (solid red if overcommitted, solid gray otherwise)
    const boundaryX = xScale.getPixelForValue(poolTotalTB);
    ctx.save();
    ctx.strokeStyle = '#323130';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(boundaryX, chart.chartArea.top - 4);
    ctx.lineTo(boundaryX, chart.chartArea.bottom + 4);
    ctx.stroke();
    ctx.fillStyle = '#323130';
    ctx.font = '11px Segoe UI,Arial,sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('Pool total', boundaryX, chart.chartArea.top - 8);
    // Reserve boundary line (dashed amber)
    if (reserveTB > 0) {
      const reserveX = xScale.getPixelForValue(reserveTB);
      ctx.strokeStyle = '#e8a218';
      ctx.lineWidth = 2;
      ctx.setLineDash([5, 4]);
      ctx.beginPath();
      ctx.moveTo(reserveX, chart.chartArea.top - 4);
      ctx.lineTo(reserveX, chart.chartArea.bottom + 4);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = '#e8a218';
      ctx.textAlign = 'center';
      ctx.fillText('Reserve', reserveX, chart.chartArea.bottom + 14);
    }
    ctx.restore();
  }
};

const pbCtx = document.getElementById('poolBreakdownChart').getContext('2d');
new Chart(pbCtx, {
  type: 'bar',
  data: {
    labels: ['Pool'],
    datasets: [$poolBarDatasets]
  },
  options: {
    responsive: true, maintainAspectRatio: false,
    indexAxis: 'y',
    layout: { padding: { top: 20, bottom: 4 } },
    plugins: {
      legend: { position: 'bottom', labels: { boxWidth: 14, font: { size: 11 } } },
      tooltip: { callbacks: { label: ctx => ctx.dataset.label } }
    },
    scales: {
      x: {
        stacked: true,
        beginAtZero: true,
        title: { display: true, text: 'TB' },
        grid: { color: '#edebe9' }
      },
      y: { stacked: true, display: false }
    }
  },
  plugins: [reserveLine]
});

// ── Storage Pool Health bar ───────────────────────────────────────────────
const ph = {
  used: $phUsed,
  free: $phFree,
  reserveOk: $phReserveOk,
  reserveEaten: $phReserveEaten,
  overcommit: $phOvercommit,
  poolTotal: $poolTotalTB,
  reserveLine: $phAvailLine
};

function makeHazard(ctx) {
  const sz = 10;
  const c = document.createElement('canvas');
  c.width = sz; c.height = sz;
  const p = c.getContext('2d');
  p.fillStyle = '#fde7e9';
  p.fillRect(0, 0, sz, sz);
  p.strokeStyle = '#d13438';
  p.lineWidth = 2.5;
  [[0, sz, sz, 0], [-sz * 0.5, sz * 0.5, sz * 0.5, -sz * 0.5], [sz * 0.5, sz * 1.5, sz * 1.5, sz * 0.5]].forEach(([x1,y1,x2,y2]) => {
    p.beginPath(); p.moveTo(x1, y1); p.lineTo(x2, y2); p.stroke();
  });
  return ctx.createPattern(c, 'repeat');
}

const phBoundaryPlugin = {
  id: 'phBoundary',
  afterDraw(chart) {
    const ctx = chart.ctx, x = chart.scales.x, top = chart.chartArea.top, bot = chart.chartArea.bottom;
    const drawLine = (val, color, dash, label, labelPos) => {
      const px = x.getPixelForValue(val);
      ctx.save();
      ctx.strokeStyle = color; ctx.lineWidth = 2;
      if (dash) ctx.setLineDash([5, 4]);
      ctx.beginPath(); ctx.moveTo(px, top - 6); ctx.lineTo(px, bot + 6); ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = color; ctx.font = 'bold 11px Segoe UI,Arial,sans-serif'; ctx.textAlign = 'center';
      ctx.fillText(label, px, labelPos === 'top' ? top - 10 : bot + 18);
      ctx.restore();
    };
    if (ph.reserveLine > 0) drawLine(ph.reserveLine, '#e8a218', true, 'Reserve starts', 'top');
    if (ph.poolTotal > 0) drawLine(ph.poolTotal, '#323130', false, 'Pool total', 'top');
  }
};

const phCtx = document.getElementById('poolHealthChart').getContext('2d');
const hazard = makeHazard(phCtx);
const phDatasets = [
  { label: 'Volumes ' + ph.used + ' TB', data: [ph.used], backgroundColor: '#0078d4', borderWidth: 0 },
  { label: 'Free ' + ph.free + ' TB', data: [ph.free], backgroundColor: '#dff6dd', borderWidth: 1, borderColor: '#107c10' },
  { label: 'Reserve — OK ' + ph.reserveOk + ' TB', data: [ph.reserveOk], backgroundColor: '#e8a218', borderWidth: 0 },
  { label: 'Reserve — consumed ' + ph.reserveEaten + ' TB', data: [ph.reserveEaten], backgroundColor: hazard, borderWidth: 0 },
  { label: 'Overcommit ' + ph.overcommit + ' TB', data: [ph.overcommit], backgroundColor: '#a80000', borderWidth: 0 }
].filter(d => d.data[0] > 0);

new Chart(phCtx, {
  type: 'bar',
  data: { labels: [''], datasets: phDatasets },
  options: {
    responsive: true, maintainAspectRatio: false,
    indexAxis: 'y',
    layout: { padding: { top: 28, bottom: 4, left: 4, right: 8 } },
    plugins: {
      legend: { display: false },
      tooltip: { callbacks: { label: ctx => ctx.dataset.label } }
    },
    scales: {
      x: {
        stacked: true, beginAtZero: true,
        max: Math.ceil(Math.max(ph.poolTotal, ph.used + ph.reserveEaten + ph.overcommit) * 1.08),
        title: { display: true, text: 'TB' },
        grid: { color: '#edebe9' }
      },
      y: { stacked: true, display: false }
    }
  },
  plugins: [phBoundaryPlugin]
});

// Custom legend for pool health bar
(function() {
  const wrap = document.getElementById('poolHealthChart').parentElement;
  const leg = document.createElement('div');
  leg.style.cssText = 'display:flex;flex-wrap:wrap;gap:16px;margin-top:8px;font-size:12px;align-items:center';
  const items = [
    { color: '#0078d4', label: 'Volumes used' },
    { color: '#dff6dd', label: 'Free', border: '#107c10' },
    { color: '#e8a218', label: 'Reserve — intact' },
    { color: '#d13438', label: 'Reserve — consumed', hazard: true },
    { color: '#a80000', label: 'Overcommit (past pool total)' }
  ];
  const phMap = { 'Volumes used': ph.used, 'Free': ph.free, 'Reserve — intact': ph.reserveOk, 'Reserve — consumed': ph.reserveEaten, 'Overcommit (past pool total)': ph.overcommit };
  items.filter(i => phMap[i.label] > 0).forEach(i => {
    const d = document.createElement('div');
    d.style.cssText = 'display:flex;align-items:center;gap:6px';
    const swatch = document.createElement('div');
    swatch.style.cssText = 'width:14px;height:14px;border-radius:2px;flex-shrink:0';
    if (i.hazard) {
      swatch.style.background = 'repeating-linear-gradient(45deg,#fde7e9 0px,#fde7e9 4px,#d13438 4px,#d13438 7px)';
    } else {
      swatch.style.background = i.color;
      if (i.border) swatch.style.border = '1px solid ' + i.border;
    }
    d.appendChild(swatch);
    d.appendChild(Object.assign(document.createElement('span'), { textContent: i.label + ' ' + phMap[i.label] + ' TB' }));
    leg.appendChild(d);
  });
  wrap.appendChild(leg);
})();
</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
}