Modules/Private/Export-S2DWordReport.ps1
|
# Word (.docx) report exporter — generates Open XML without requiring Office function Export-S2DWordReport { param( [Parameter(Mandatory)] [S2DClusterData] $ClusterData, [Parameter(Mandatory)] [string] $OutputPath, [string] $Author = '', [string] $Company = '' ) $dir = Split-Path $OutputPath -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $cn = $ClusterData.ClusterName $nc = $ClusterData.NodeCount $wf = $ClusterData.CapacityWaterfall $pool = $ClusterData.StoragePool $vols = @($ClusterData.Volumes) $disks = @($ClusterData.PhysicalDisks) $hc = @($ClusterData.HealthChecks) $oh = $ClusterData.OverallHealth $date = Get-Date -Format 'MMMM d, yyyy' # ── Build document XML ──────────────────────────────────────────────────── function local:P { param([string]$text,[string]$style='Normal') "<w:p><w:pPr><w:pStyle w:val='$style'/></w:pPr><w:r><w:t xml:space='preserve'>$([System.Security.SecurityElement]::Escape($text))</w:t></w:r></w:p>" } function local:H1 { param([string]$t) "<w:p><w:pPr><w:pStyle w:val='Heading1'/></w:pPr><w:r><w:t>$([System.Security.SecurityElement]::Escape($t))</w:t></w:r></w:p>" } function local:H2 { param([string]$t) "<w:p><w:pPr><w:pStyle w:val='Heading2'/></w:pPr><w:r><w:t>$([System.Security.SecurityElement]::Escape($t))</w:t></w:r></w:p>" } function local:BR { "<w:p/>" } function local:Bold { param([string]$l,[string]$v) "<w:p><w:r><w:rPr><w:b/></w:rPr><w:t xml:space='preserve'>$([System.Security.SecurityElement]::Escape($l))</w:t></w:r><w:r><w:t xml:space='preserve'> $([System.Security.SecurityElement]::Escape($v))</w:t></w:r></w:p>" } function local:TRow { param([string[]]$cells,[bool]$isHeader=$false) $rp = if ($isHeader) { "<w:trPr><w:tblHeader/></w:trPr>" } else { "" } $cs = $cells | ForEach-Object { $shd = if ($isHeader) { "<w:shd w:val='clear' w:color='auto' w:fill='0078D4'/>" } else { "" } $rp2 = if ($isHeader) { "<w:rPr><w:b/><w:color w:val='FFFFFF'/></w:rPr>" } else { "" } "<w:tc><w:tcPr><w:tcW w:w='0' w:type='auto'/>$shd</w:tcPr><w:p><w:r>$rp2<w:t>$([System.Security.SecurityElement]::Escape($_))</w:t></w:r></w:p></w:tc>" } "<w:tr>$rp$($cs -join '')</w:tr>" } function local:Table { param([string[]]$headers,[object[]]$rows,[string[]]$props) $hrow = TRow -cells $headers -isHeader $true $drows = $rows | ForEach-Object { $obj = $_ $cells = $props | ForEach-Object { $v = $obj.$_; if ($null -eq $v) { '' } else { [string]$v } } TRow -cells $cells } "<w:tbl><w:tblPr><w:tblW w:w='0' w:type='auto'/><w:tblBorders><w:top w:val='single' w:sz='4' w:color='EDEBE9'/><w:left w:val='single' w:sz='4' w:color='EDEBE9'/><w:bottom w:val='single' w:sz='4' w:color='EDEBE9'/><w:right w:val='single' w:sz='4' w:color='EDEBE9'/><w:insideH w:val='single' w:sz='4' w:color='EDEBE9'/><w:insideV w:val='single' w:sz='4' w:color='EDEBE9'/></w:tblBorders></w:tblPr>$hrow$($drows -join '')</w:tbl>" } $body = @() # Cover page $body += H1 "S2D Cartographer — $cn" $body += P "Storage Spaces Direct Analysis Report" $body += P "Generated: $date" if ($Author) { $body += P "Prepared by: $Author" } if ($Company) { $body += P "Organization: $Company" } $body += BR # Executive Summary $body += H1 "Executive Summary" $body += Bold "Cluster:" $cn $body += Bold "Nodes:" $nc $body += Bold "Overall Health:" $oh if ($wf) { $body += Bold "Raw Capacity:" "$($wf.RawCapacity.TiB) TiB ($($wf.RawCapacity.TB) TB)" $body += Bold "Usable Capacity:" "$($wf.UsableCapacity.TiB) TiB ($($wf.UsableCapacity.TB) TB)" $body += Bold "Reserve Status:" $wf.ReserveStatus $body += Bold "Resiliency Efficiency:" "$($wf.BlendedEfficiencyPercent)%" } $body += BR # Capacity Waterfall $body += H1 "Capacity Waterfall" $body += P "The following table shows the 8-stage capacity reduction from raw physical disks to final usable storage." if ($wf) { $wfRows = $wf.Stages | ForEach-Object { [PSCustomObject]@{ Stage = "Stage $($_.Stage)"; Name = $_.Name TiB = if ($_.Size) { "$($_.Size.TiB) TiB" } else { '0 TiB' } TB = if ($_.Size) { "$($_.Size.TB) TB" } else { '0 TB' } Status = $_.Status; Description = $_.Description } } $body += Table -headers @('Stage','Name','TiB','TB','Status','Description') -rows $wfRows -props @('Stage','Name','TiB','TB','Status','Description') } $body += BR # Physical Disk Inventory $body += H1 "Physical Disk Inventory" $diskRows = $disks | ForEach-Object { [PSCustomObject]@{ Node = $_.NodeName; Model = $_.FriendlyName; Type = $_.MediaType Role = $_.Role; Size = if($_.Size){"$($_.Size.TiB) TiB"}else{'N/A'} Wear = if($null -ne $_.WearPercentage){"$($_.WearPercentage)%"}else{'N/A'} Health = $_.HealthStatus } } $body += Table -headers @('Node','Model','Type','Role','Size','Wear %','Health') -rows $diskRows -props @('Node','Model','Type','Role','Size','Wear','Health') $body += BR # Volume Map $body += H1 "Volume Map" $volRows = $vols | ForEach-Object { [PSCustomObject]@{ Name = $_.FriendlyName; Resiliency = "$($_.ResiliencySettingName) ($($_.NumberOfDataCopies)x)" Size = if($_.Size){"$($_.Size.TiB) TiB"}else{'N/A'} Footprint = if($_.FootprintOnPool){"$($_.FootprintOnPool.TiB) TiB"}else{'N/A'} Eff = "$($_.EfficiencyPercent)%"; Prov = $_.ProvisioningType; Health = $_.HealthStatus } } $body += Table -headers @('Volume','Resiliency','Size','Pool Footprint','Efficiency','Provisioning','Health') -rows $volRows -props @('Name','Resiliency','Size','Footprint','Eff','Prov','Health') $body += BR # Health Checks $body += H1 "Health Assessment" foreach ($check in $hc) { $body += H2 "$($check.CheckName) [$($check.Severity)] — $($check.Status)" $body += P $check.Details if ($check.Status -ne 'Pass' -and $check.Remediation) { $body += Bold "Remediation:" $check.Remediation } $body += BR } # Appendix A: TiB vs TB $body += H1 "Appendix A — TiB vs TB Explanation" $body += P "Drive manufacturers label storage in decimal terabytes (1 TB = 1,000,000,000,000 bytes). Windows and S2D report capacity in binary tebibytes (1 TiB = 1,099,511,627,776 bytes). This creates an apparent ~9% difference. All data is present and accounted for — the discrepancy is purely a unit conversion." $tibTable = @( [PSCustomObject]@{ Label='0.96 TB'; Windows='0.873 TiB'; Diff='-9.3%' } [PSCustomObject]@{ Label='1.92 TB'; Windows='1.747 TiB'; Diff='-9.0%' } [PSCustomObject]@{ Label='3.84 TB'; Windows='3.492 TiB'; Diff='-9.1%' } [PSCustomObject]@{ Label='7.68 TB'; Windows='6.986 TiB'; Diff='-9.0%' } [PSCustomObject]@{ Label='15.36 TB'; Windows='13.97 TiB'; Diff='-9.1%' } ) $body += Table -headers @('Drive Label','Windows Reports','Difference') -rows $tibTable -props @('Label','Windows','Diff') $body += BR # Appendix B: Reserve Best Practices $body += H1 "Appendix B — S2D Reserve Space Best Practices" $body += P "Microsoft recommends keeping at least min(NodeCount, 4) × (largest capacity drive size) of unallocated pool space. This reserve enables full rebuild after a drive or node failure. For a $nc-node cluster with $($wf ? "$($wf.ReserveRecommended.TB) TB" : 'N/A') largest drives, the recommended reserve is $($wf ? "$($wf.ReserveRecommended.TiB) TiB" : 'N/A')." $bodyXml = $body -join "`n" # ── Assemble DOCX (ZIP of XML) ──────────────────────────────────────────── $contentTypesXml = @' <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> <Default Extension="xml" ContentType="application/xml"/> <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/> <Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/> </Types> '@ $relsXml = @' <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/> </Relationships> '@ $wordRelsXml = @' <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/> </Relationships> '@ $stylesXml = @' <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> <w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="22"/></w:rPr></w:style> <w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/> <w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr> <w:rPr><w:b/><w:sz w:val="32"/><w:color w:val="0078D4"/></w:rPr> </w:style> <w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/> <w:pPr><w:spacing w:before="160" w:after="80"/></w:pPr> <w:rPr><w:b/><w:sz w:val="26"/><w:color w:val="323130"/></w:rPr> </w:style> </w:styles> '@ $documentXml = @" <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> <w:body> $bodyXml <w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr> </w:body> </w:document> "@ # Write to zip Add-Type -AssemblyName System.IO.Compression.FileSystem $ms = [System.IO.MemoryStream]::new() $zip = [System.IO.Compression.ZipArchive]::new($ms, [System.IO.Compression.ZipArchiveMode]::Create, $true) function local:AddEntry { param([string]$name,[string]$content) $e = $zip.CreateEntry($name) $sw = [System.IO.StreamWriter]::new($e.Open()) $sw.Write($content); $sw.Close() } AddEntry '[Content_Types].xml' $contentTypesXml AddEntry '_rels/.rels' $relsXml AddEntry 'word/_rels/document.xml.rels' $wordRelsXml AddEntry 'word/styles.xml' $stylesXml AddEntry 'word/document.xml' $documentXml $zip.Dispose() [System.IO.File]::WriteAllBytes($OutputPath, $ms.ToArray()) $ms.Dispose() Write-Verbose "Word report written to $OutputPath" $OutputPath } |