Modules/Private/New-S2DSvgDiagram.ps1
|
# SVG diagram generation engine for S2DCartographer function New-S2DWaterfallSvg { param([S2DCapacityWaterfall]$Waterfall, [string]$PrimaryUnit = 'TiB') $w = 900; $h = 520 $barH = 36; $barGap = 10; $leftPad = 220; $rightPad = 20; $topPad = 60; $legendH = 50 $colors = @{ 'Raw Physical' = '#0078d4' 'Vendor Label (TB)' = '#005a9e' 'Pool (after overhead)' = '#106ebe' 'After Reserve' = '#e8a218' 'After Infra Volume' = '#d47a00' 'Available' = '#107c10' 'After Resiliency' = '#0e6e0e' 'Final Usable' = '#054b05' } $maxBytes = $Waterfall.RawCapacity.Bytes $chartW = $w - $leftPad - $rightPad $bars = '' $i = 0 foreach ($stage in $Waterfall.Stages) { $y = $topPad + $i * ($barH + $barGap) $bytes = if ($stage.Size) { $stage.Size.Bytes } else { 0 } $pct = if ($maxBytes -gt 0) { $bytes / $maxBytes } else { 0 } $bw = [math]::Max(2, [int]($chartW * $pct)) $color = if ($colors.ContainsKey($stage.Name)) { $colors[$stage.Name] } else { '#888888' } $label = if ($PrimaryUnit -eq 'TiB') { "$($stage.Size.TiB) TiB" } else { "$($stage.Size.TB) TB" } $statusMark = if ($stage.Status -eq 'Warning') { ' ⚠' } elseif ($stage.Status -eq 'Critical') { ' ✖' } else { '' } $bars += @" <g> <text x="$($leftPad - 8)" y="$($y + $barH/2 + 5)" text-anchor="end" font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#323130">Stage $($stage.Stage): $($stage.Name)</text> <rect x="$leftPad" y="$y" width="$bw" height="$barH" fill="$color" rx="3"/> <text x="$($leftPad + $bw + 6)" y="$($y + $barH/2 + 5)" font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#323130">$label$statusMark</text> </g> "@ $i++ } $svgH = $topPad + $i * ($barH + $barGap) + $legendH @" <svg xmlns="http://www.w3.org/2000/svg" width="$w" height="$svgH" viewBox="0 0 $w $svgH"> <rect width="$w" height="$svgH" fill="#faf9f8" rx="8"/> <text x="$(($w/2))" y="36" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="18" font-weight="600" fill="#201f1e">S2D Capacity Waterfall</text> <line x1="$leftPad" y1="$topPad" x2="$leftPad" y2="$($topPad + $i * ($barH + $barGap))" stroke="#c8c6c4" stroke-width="1"/> $bars <text x="$($w/2)" y="$($svgH - 12)" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="11" fill="#605e5c">Raw: $($Waterfall.RawCapacity.TiB) TiB | Usable: $($Waterfall.UsableCapacity.TiB) TiB | Reserve: $($Waterfall.ReserveStatus) | Efficiency: $($Waterfall.BlendedEfficiencyPercent)%</text> </svg> "@ } function New-S2DDiskNodeMapSvg { param([object[]]$PhysicalDisks) $nodeGroups = @($PhysicalDisks | Group-Object NodeName) $nodeCount = $nodeGroups.Count $nodeW = 200; $nodeGap = 20; $diskH = 28; $diskGap = 4 $headerH = 40; $footerH = 30; $padH = 16; $padW = 12 $diskColors = @{ Cache = '#0078d4' # blue Capacity = '#008272' # teal Unknown = '#8a8886' # gray } $healthAlert = '#d13438' # red for unhealthy $totalW = $nodeCount * $nodeW + ($nodeCount - 1) * $nodeGap + 40 $maxDisksPerNode = ($nodeGroups | ForEach-Object { $_.Group.Count } | Measure-Object -Maximum).Maximum $nodeH = $headerH + $padH + $maxDisksPerNode * ($diskH + $diskGap) + $footerH $nodes = '' $ni = 0 foreach ($group in $nodeGroups) { $nx = 20 + $ni * ($nodeW + $nodeGap) $nodes += "<rect x='$nx' y='20' width='$nodeW' height='$nodeH' fill='#f3f2f1' stroke='#c8c6c4' stroke-width='1.5' rx='6'/>" $shortName = $group.Name -replace '\..*$', '' $nodes += "<text x='$($nx + $nodeW/2)' y='46' text-anchor='middle' font-family='Segoe UI,Arial,sans-serif' font-size='13' font-weight='600' fill='#201f1e'>$shortName</text>" $di = 0 foreach ($disk in $group.Group) { $dy = 20 + $headerH + $padH + $di * ($diskH + $diskGap) $role = if ($disk.PSObject.Properties['Role']) { $disk.Role } else { 'Unknown' } $isUnhealthy = $disk.HealthStatus -ne 'Healthy' $color = if ($isUnhealthy) { $healthAlert } elseif ($diskColors.ContainsKey($role)) { $diskColors[$role] } else { $diskColors['Unknown'] } $label = "$($disk.FriendlyName -replace '^.{0,5}', '' | Select-Object -First 1)$($disk.MediaType)" $sizeLabel = if ($disk.Size) { $disk.Size.TB.ToString('N2') + 'TB' } else { '' } $nodes += "<rect x='$($nx+$padW)' y='$dy' width='$($nodeW - 2*$padW)' height='$diskH' fill='$color' rx='3'/>" $nodes += "<text x='$($nx+$padW+6)' y='$($dy+$diskH/2+5)' font-family='Segoe UI,Arial,sans-serif' font-size='10' fill='white'>$($disk.MediaType) $sizeLabel</text>" $di++ } $totalCap = [math]::Round(($group.Group | Where-Object { $_.SizeBytes } | Measure-Object -Property SizeBytes -Sum).Sum / 1TB, 1) $nodes += "<text x='$($nx + $nodeW/2)' y='$($20 + $nodeH - 10)' text-anchor='middle' font-family='Segoe UI,Arial,sans-serif' font-size='11' fill='#323130'>Total: $totalCap TB</text>" $ni++ } $svgH = $nodeH + 80 @" <svg xmlns="http://www.w3.org/2000/svg" width="$totalW" height="$svgH" viewBox="0 0 $totalW $svgH"> <rect width="$totalW" height="$svgH" fill="#ffffff" rx="8"/> <text x="$($totalW/2)" y="16" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="16" font-weight="600" fill="#201f1e">Disk-to-Node Map</text> $nodes <g transform="translate(20,$($svgH-28))"> <rect width="14" height="14" fill="#0078d4" rx="2"/><text x="18" y="11" font-family="Segoe UI,Arial,sans-serif" font-size="11" fill="#323130">Cache</text> <rect x="70" width="14" height="14" fill="#008272" rx="2"/><text x="88" y="11" font-family="Segoe UI,Arial,sans-serif" font-size="11" fill="#323130">Capacity</text> <rect x="150" width="14" height="14" fill="#d13438" rx="2"/><text x="168" y="11" font-family="Segoe UI,Arial,sans-serif" font-size="11" fill="#323130">Unhealthy</text> </g> </svg> "@ } function New-S2DPoolLayoutSvg { param([S2DStoragePool]$Pool, [S2DCapacityWaterfall]$Waterfall, [object[]]$Volumes) $w = 600; $h = 400; $cx = 200; $cy = 200; $r = 160 $totalBytes = if ($Pool.TotalSize) { $Pool.TotalSize.Bytes } else { 1 } $infraBytes = [int64](($Volumes | Where-Object IsInfrastructureVolume | ForEach-Object { if($_.FootprintOnPool){$_.FootprintOnPool.Bytes}else{0} } | Measure-Object -Sum).Sum) $workloadBytes = [int64](($Volumes | Where-Object { -not $_.IsInfrastructureVolume } | ForEach-Object { if($_.FootprintOnPool){$_.FootprintOnPool.Bytes}else{0} } | Measure-Object -Sum).Sum) $reserveBytes = if ($Waterfall.ReserveRecommended) { $Waterfall.ReserveRecommended.Bytes } else { 0 } $freeBytes = [math]::Max(0, $totalBytes - $workloadBytes - $infraBytes - $reserveBytes) function local:Slice { param($start, $end, $color, $label) $sa = ($start / $totalBytes) * 2 * [math]::PI - [math]::PI/2 $ea = ($end / $totalBytes) * 2 * [math]::PI - [math]::PI/2 $x1 = $cx + $r * [math]::Cos($sa); $y1 = $cy + $r * [math]::Sin($sa) $x2 = $cx + $r * [math]::Cos($ea); $y2 = $cy + $r * [math]::Sin($ea) $large = if (($end - $start) / $totalBytes -gt 0.5) { 1 } else { 0 } "<path d='M $cx $cy L $([math]::Round($x1,1)) $([math]::Round($y1,1)) A $r $r 0 $large 1 $([math]::Round($x2,1)) $([math]::Round($y2,1)) Z' fill='$color'/>" } $slices = '' $pos = 0 $slices += Slice $pos ($pos + $workloadBytes) '#0078d4' 'Workload'; $pos += $workloadBytes $slices += Slice $pos ($pos + $reserveBytes) '#e8a218' 'Reserve'; $pos += $reserveBytes $slices += Slice $pos ($pos + $infraBytes) '#d47a00' 'Infra'; $pos += $infraBytes $slices += Slice $pos ($pos + $freeBytes) '#c8c6c4' 'Free' $wl = [math]::Round($workloadBytes/1TB, 1); $rv = [math]::Round($reserveBytes/1TB, 1) $inf = [math]::Round($infraBytes/1TB, 1); $fr = [math]::Round($freeBytes/1TB, 1) @" <svg xmlns="http://www.w3.org/2000/svg" width="$w" height="$h" viewBox="0 0 $w $h"> <rect width="$w" height="$h" fill="#faf9f8" rx="8"/> <text x="$($w/2)" y="28" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="16" font-weight="600" fill="#201f1e">Storage Pool Layout</text> $slices <circle cx="$cx" cy="$cy" r="70" fill="white"/> <text x="$cx" y="$($cy-10)" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="13" font-weight="600" fill="#201f1e">$($Pool.FriendlyName)</text> <text x="$cx" y="$($cy+10)" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="11" fill="#605e5c">$([math]::Round($totalBytes/1TB,1)) TB</text> <g transform="translate(410,80)"> <rect y="0" width="14" height="14" fill="#0078d4" rx="2"/><text x="18" y="11" font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#323130">Workload $wl TB</text> <rect y="24" width="14" height="14" fill="#e8a218" rx="2"/><text x="18" y="35" font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#323130">Reserve $rv TB</text> <rect y="48" width="14" height="14" fill="#d47a00" rx="2"/><text x="18" y="59" font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#323130">Infra $inf TB</text> <rect y="72" width="14" height="14" fill="#c8c6c4" rx="2"/><text x="18" y="83" font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#323130">Free $fr TB</text> </g> </svg> "@ } function New-S2DHealthScorecardSvg { param([S2DHealthCheck[]]$HealthChecks, [string]$OverallHealth = 'Unknown') $colors = @{ Pass='#107c10'; Warn='#e8a218'; Fail='#d13438'; Unknown='#8a8886' } $bgColors = @{ Pass='#dff6dd'; Warn='#fff4ce'; Fail='#fde7e9'; Unknown='#f3f2f1' } $overallColor = switch ($OverallHealth) { 'Healthy'{'#107c10'} 'Warning'{'#e8a218'} 'Critical'{'#d13438'} default{'#8a8886'} } $cardW = 820; $cardH = 60; $cardGap = 8; $padX = 20; $topPad = 80 $cards = '' $i = 0 foreach ($check in $HealthChecks) { $y = $topPad + $i * ($cardH + $cardGap) $status = if ($check.Status -eq 'Warn') { 'Warn' } else { $check.Status } $c = if ($colors.ContainsKey($status)) { $colors[$status] } else { $colors['Unknown'] } $bg = if ($bgColors.ContainsKey($status)) { $bgColors[$status] } else { $bgColors['Unknown'] } $icon = switch ($status) { 'Pass'{'✔'} 'Warn'{'⚠'} 'Fail'{'✖'} default{'?'} } $cards += @" <rect x="$padX" y="$y" width="$cardW" height="$cardH" fill="$bg" stroke="$c" stroke-width="1.5" rx="4"/> <text x="$($padX+16)" y="$($y+24)" font-family="Segoe UI,Arial,sans-serif" font-size="16" fill="$c">$icon</text> <text x="$($padX+40)" y="$($y+22)" font-family="Segoe UI,Arial,sans-serif" font-size="13" font-weight="600" fill="#201f1e">$($check.CheckName) <tspan font-weight="400" fill="#605e5c">[$($check.Severity)]</tspan></text> <text x="$($padX+40)" y="$($y+42)" font-family="Segoe UI,Arial,sans-serif" font-size="11" fill="#323130">$([System.Security.SecurityElement]::Escape($(if($check.Details.Length -gt 100){$check.Details.Substring(0,97)+'...'}else{$check.Details})))</text> "@ $i++ } $svgH = $topPad + $i * ($cardH + $cardGap) + 30 @" <svg xmlns="http://www.w3.org/2000/svg" width="860" height="$svgH" viewBox="0 0 860 $svgH"> <rect width="860" height="$svgH" fill="#ffffff" rx="8"/> <text x="430" y="32" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="18" font-weight="600" fill="#201f1e">Health Scorecard</text> <rect x="$padX" y="44" width="$cardW" height="28" fill="$overallColor" rx="4"/> <text x="430" y="63" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="13" font-weight="600" fill="white">Overall Health: $OverallHealth</text> $cards </svg> "@ } function New-S2DTiBTBReferenceSvg { $sizes = @( @{ TB='0.96'; TiB='0.873'; Diff='9.3%' } @{ TB='1.92'; TiB='1.747'; Diff='9.0%' } @{ TB='3.84'; TiB='3.492'; Diff='9.1%' } @{ TB='7.68'; TiB='6.986'; Diff='9.0%' } @{ TB='15.36'; TiB='13.97'; Diff='9.1%' } @{ TB='30.72'; TiB='27.94'; Diff='9.0%' } ) $w = 600; $rowH = 36; $headerH = 80; $colX = @(30, 160, 300, 440) $svgH = $headerH + $sizes.Count * $rowH + 40 $rows = '' $i = 0 foreach ($s in $sizes) { $y = $headerH + $i * $rowH $bg = if ($i % 2 -eq 0) { '#f3f2f1' } else { '#ffffff' } $rows += "<rect x='20' y='$y' width='560' height='$rowH' fill='$bg'/>" $rows += "<text x='$($colX[0]+8)' y='$($y+24)' font-family='Segoe UI,Arial,sans-serif' font-size='13' fill='#201f1e'>$($s.TB) TB</text>" $rows += "<text x='$($colX[1]+8)' y='$($y+24)' font-family='Segoe UI,Arial,sans-serif' font-size='13' fill='#201f1e'>$($s.TiB) TiB</text>" $rows += "<text x='$($colX[2]+8)' y='$($y+24)' font-family='Segoe UI,Arial,sans-serif' font-size='13' fill='#d13438'>-$($s.Diff)</text>" $i++ } @" <svg xmlns="http://www.w3.org/2000/svg" width="$w" height="$svgH" viewBox="0 0 $w $svgH"> <rect width="$w" height="$svgH" fill="#ffffff" rx="8"/> <text x="300" y="28" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="16" font-weight="600" fill="#201f1e">TiB vs TB Reference</text> <text x="300" y="50" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#605e5c">1 TB (decimal) = 0.909 TiB (binary). S2D reports in TiB; drives are labeled in TB.</text> <rect x="20" y="60" width="560" height="$rowH" fill="#0078d4" rx="4"/> <text x="$($colX[0]+8)" y="83" font-family="Segoe UI,Arial,sans-serif" font-size="13" font-weight="600" fill="white">Drive Label (TB)</text> <text x="$($colX[1]+8)" y="83" font-family="Segoe UI,Arial,sans-serif" font-size="13" font-weight="600" fill="white">Windows Sees (TiB)</text> <text x="$($colX[2]+8)" y="83" font-family="Segoe UI,Arial,sans-serif" font-size="13" font-weight="600" fill="white">Difference</text> $rows </svg> "@ } function New-S2DVolumeResiliencySvg { param([S2DVolume[]]$Volumes, [int]$NodeCount = 4) $workload = @($Volumes | Where-Object { -not $_.IsInfrastructureVolume }) $rowH = 50; $headerH = 70; $w = 800 $svgH = $headerH + $workload.Count * $rowH + 30 $rows = '' $i = 0 foreach ($vol in $workload) { $y = $headerH + $i * $rowH $bg = if ($i % 2 -eq 0) { '#f3f2f1' } else { '#ffffff' } $eff = "$($vol.EfficiencyPercent)%" $resType = switch ($vol.NumberOfDataCopies) { 2 { if ($NodeCount -le 2) { 'Nested 2-Way Mirror' } else { '2-Way Mirror' } } 3 { '3-Way Mirror' } default { "$($vol.ResiliencySettingName) ($($vol.NumberOfDataCopies) copies)" } } $sz = if ($vol.Size) { "$($vol.Size.TiB) TiB" } else { 'N/A' } $fp = if ($vol.FootprintOnPool) { "$($vol.FootprintOnPool.TiB) TiB" } else { 'N/A' } $healthColor = if ($vol.HealthStatus -eq 'Healthy') { '#107c10' } else { '#d13438' } $rows += "<rect x='10' y='$y' width='780' height='$rowH' fill='$bg'/>" $rows += "<text x='18' y='$($y+22)' font-family='Segoe UI,Arial,sans-serif' font-size='12' font-weight='600' fill='#201f1e'>$($vol.FriendlyName)</text>" $rows += "<text x='18' y='$($y+40)' font-family='Segoe UI,Arial,sans-serif' font-size='11' fill='#605e5c'>$resType | Size: $sz | Footprint: $fp | Efficiency: $eff | Prov: $($vol.ProvisioningType)</text>" $rows += "<circle cx='770' cy='$($y+25)' r='8' fill='$healthColor'/>" $i++ } @" <svg xmlns="http://www.w3.org/2000/svg" width="$w" height="$svgH" viewBox="0 0 $w $svgH"> <rect width="$w" height="$svgH" fill="#ffffff" rx="8"/> <text x="400" y="28" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="16" font-weight="600" fill="#201f1e">Volume Resiliency Map</text> <rect x="10" y="40" width="780" height="$($rowH - 4)" fill="#0078d4" rx="4"/> <text x="18" y="62" font-family="Segoe UI,Arial,sans-serif" font-size="12" font-weight="600" fill="white">Volume Name | Resiliency | Size | Footprint | Efficiency | Provisioning</text> $rows </svg> "@ } |