From 174a0967bcda216cbb3490589a01d36fa74dffa4 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 16:05:14 +0200 Subject: [PATCH 1/4] feat: add ContentDatabase inventory HTML report (Export-SPSUpdateDbReport) Introduce a self-contained, offline HTML report for the ContentDatabase inventory, styled after the SPSUserSync reports: - Enrich Initialize-SPSContentDbJsonFile: persist SizeInBytes/SizeInMB per database in the inventory JSON (backward compatible - mount/upgrade still read only Name/WebAppUrl/Server). - New private helpers: ConvertTo-SPSHtmlEncoded and Get-SPSReportAssets (summary card, document head + embedded CSS, per-sequence distribution bars, and the vanilla-JS interactive table with numeric-aware sorting). - New public Export-SPSUpdateDbReport: reads a *-ContentDBs.json inventory and renders summary cards (total DBs, total MB, balance spread), the LPT distribution per sequence, and a sortable/filterable table. Tolerates pre-4.1.0 inventories without size (shows 'n/a', distributes by count). - Bump module manifest to 4.1.0 and export the new function. PSScriptAnalyzer clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Private/ConvertTo-SPSHtmlEncoded.ps1 | 50 ++++ .../Private/Get-SPSReportAssets.ps1 | 210 ++++++++++++++ .../Public/Export-SPSUpdateDbReport.ps1 | 264 ++++++++++++++++++ .../Initialize-SPSContentDbJsonFile.ps1 | 10 +- .../SPSUpdate.Common/SPSUpdate.Common.psd1 | 3 +- 5 files changed, 533 insertions(+), 4 deletions(-) create mode 100644 src/Modules/SPSUpdate.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 create mode 100644 src/Modules/SPSUpdate.Common/Private/Get-SPSReportAssets.ps1 create mode 100644 src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateDbReport.ps1 diff --git a/src/Modules/SPSUpdate.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 b/src/Modules/SPSUpdate.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 new file mode 100644 index 0000000..b1c05f9 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/ConvertTo-SPSHtmlEncoded.ps1 @@ -0,0 +1,50 @@ +function ConvertTo-SPSHtmlEncoded { + <# + .SYNOPSIS + HTML-encodes a string for safe insertion into generated HTML reports. + + .DESCRIPTION + Replaces the five characters that are significant in HTML markup + (& < > " ') with their entity equivalents. Used by Export-SPSUpdateDbReport + to neutralize values (database names, server names, web application URLs) + before baking them into the report. + + Returns an empty string for null or empty input. + + .PARAMETER Value + The string to encode. + + .EXAMPLE + ConvertTo-SPSHtmlEncoded -Value 'DB & "co"' + # DB <prod> & "co" + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [AllowEmptyString()] + [AllowNull()] + [System.String] + $Value + ) + + process { + if ([string]::IsNullOrEmpty($Value)) { + return '' + } + + $sb = [System.Text.StringBuilder]::new() + foreach ($char in $Value.ToCharArray()) { + switch ($char) { + '&' { [void]$sb.Append('&') } + '<' { [void]$sb.Append('<') } + '>' { [void]$sb.Append('>') } + '"' { [void]$sb.Append('"') } + "'" { [void]$sb.Append(''') } + default { [void]$sb.Append($char) } + } + } + return $sb.ToString() + } +} diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportAssets.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportAssets.ps1 new file mode 100644 index 0000000..2494f09 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportAssets.ps1 @@ -0,0 +1,210 @@ +function Get-SPSReportCardHtml { + <# + .SYNOPSIS + Builds the HTML for one summary "card" (a big number plus a label). + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + $Value, + + [Parameter(Mandatory = $true)] + [System.String] + $Label, + + [Parameter()] + [System.String] + $Sub = '', + + [Parameter()] + [ValidateSet('', 'accent')] + [System.String] + $Tone = '' + ) + + $encValue = ConvertTo-SPSHtmlEncoded -Value ("$Value") + $encLabel = ConvertTo-SPSHtmlEncoded -Value $Label + $encSub = ConvertTo-SPSHtmlEncoded -Value $Sub + $toneClass = if ([string]::IsNullOrEmpty($Tone)) { '' } else { " $Tone" } + $subHtml = if ([string]::IsNullOrEmpty($encSub)) { '' } else { "
$encSub
" } + return "
$encValue
$encLabel
$subHtml
" +} + +function Get-SPSReportHtmlHead { + <# + .SYNOPSIS + Returns the document head (with the embedded stylesheet) and the opening body tag. + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Title + ) + + $css = @' +:root{--brand:#1f6fb2;--brand-dark:#155a91;--ink:#222;--muted:#666;--line:#e3e3e3;--zebra:#f7f9fb;--bar:#1f6fb2;--bar-bg:#e8eef5} +*{box-sizing:border-box} +body{font-family:'Aptos','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;color:var(--ink);margin:0;padding:24px;background:#fff} +h1{color:var(--brand);font-size:22px;margin:0 0 4px} +h2{color:var(--brand);font-size:16px;margin:24px 0 8px;border-bottom:2px solid var(--brand);padding-bottom:4px} +h3{color:var(--brand-dark);font-size:13px;margin:0 0 6px} +.meta{color:var(--muted);font-size:12px;margin-bottom:16px} +.summary{background:#eef5fb;border:1px solid #cfe0ef;border-left:4px solid var(--brand);border-radius:6px;padding:16px;margin-bottom:8px} +.cards{display:flex;flex-wrap:wrap;gap:12px} +.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 16px;min-width:120px} +.card-value{font-size:24px;font-weight:700;color:var(--brand)} +.card-label{font-size:12px;color:var(--muted)} +.card-sub{font-size:11px;color:var(--muted);margin-top:2px} +.card.accent{background:#eef5fb;border-color:#cfe0ef} +.dist{margin-top:14px} +.dist-row{display:flex;align-items:center;gap:10px;margin:6px 0;font-size:12px} +.dist-name{width:90px;color:var(--brand-dark);font-weight:600} +.dist-track{flex:1;background:var(--bar-bg);border-radius:4px;height:16px;overflow:hidden} +.dist-fill{background:var(--bar);height:100%} +.dist-val{width:170px;text-align:right;color:var(--muted)} +table{border-collapse:collapse;width:100%;font-size:12px} +th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top} +th{background:var(--brand);color:#fff;cursor:pointer;user-select:none;position:sticky;top:0} +td.num,th.num{text-align:right} +tbody tr:nth-child(even){background:var(--zebra)} +.controls{display:flex;justify-content:space-between;align-items:center;margin:12px 0;flex-wrap:wrap;gap:8px} +.search{padding:6px 10px;border:1px solid var(--line);border-radius:4px;font-size:13px;width:280px;max-width:100%} +.pager{display:flex;gap:8px;align-items:center;font-size:12px} +.pager button{padding:4px 10px;border:1px solid var(--line);background:#fff;border-radius:4px;cursor:pointer} +.pager button:disabled{opacity:.4;cursor:default} +.footer{color:var(--muted);font-size:11px;margin-top:24px;border-top:1px solid var(--line);padding-top:8px} +'@ + + return "$Title" +} + +function Get-SPSReportDistributionHtml { + <# + .SYNOPSIS + Builds the per-sequence distribution bars (count / MB / percentage). + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.Object[]] + $Sequences + ) + + $rowsHtml = '' + foreach ($seq in $Sequences) { + $pct = [double]$seq.Percent + $encName = ConvertTo-SPSHtmlEncoded -Value $seq.Name + $valText = '{0} db · {1:N0} MB · {2:N1}%' -f $seq.Count, $seq.SizeMB, $pct + $rowsHtml += "
$encName
" + + "
" + + "
$valText
" + } + return "
$rowsHtml
" +} + +function Get-SPSReportHtmlScript { + <# + .SYNOPSIS + Returns the vanilla-JavaScript block that renders the interactive table. + #> + [CmdletBinding()] + [OutputType([System.String])] + param () + + $js = @' +(function(){ + var node = document.getElementById('spsReportData'); + var data = JSON.parse(node.textContent || node.innerText); + var cols = data.columns || []; + var rows = data.rows || []; + var pageSize = 50, page = 1, sortField = null, sortDir = 1, view = rows; + var search = document.getElementById('spsSearch'); + var thead = document.getElementById('spsThead'); + var tbody = document.getElementById('spsTbody'); + var info = document.getElementById('spsPageInfo'); + var prev = document.getElementById('spsPrev'); + var next = document.getElementById('spsNext'); + + function isNum(c){ return c.type === 'num'; } + function buildHead(){ + var tr = document.createElement('tr'); + cols.forEach(function(c){ + var th = document.createElement('th'); + if (isNum(c)) { th.className = 'num'; } + th.textContent = c.label + ' \u2195'; + th.addEventListener('click', function(){ + if (sortField === c.field) { sortDir = -sortDir; } else { sortField = c.field; sortDir = 1; } + applySort(); render(); + }); + tr.appendChild(th); + }); + thead.appendChild(tr); + } + function applyFilter(){ + var q = (search.value || '').trim().toLowerCase(); + if (!q) { view = rows; } + else { + view = rows.filter(function(r){ + return cols.some(function(c){ + var v = r[c.field]; + return v != null && String(v).toLowerCase().indexOf(q) !== -1; + }); + }); + } + page = 1; + } + function applySort(){ + if (!sortField) { return; } + var col = null; + cols.forEach(function(c){ if (c.field === sortField) { col = c; } }); + var numeric = col && isNum(col); + view = view.slice().sort(function(a,b){ + var x = a[sortField], y = b[sortField]; + if (numeric) { + x = parseFloat(x); y = parseFloat(y); + if (isNaN(x)) { x = -Infinity; } if (isNaN(y)) { y = -Infinity; } + return (x - y) * sortDir; + } + x = x == null ? '' : String(x).toLowerCase(); + y = y == null ? '' : String(y).toLowerCase(); + if (x < y) { return -1 * sortDir; } + if (x > y) { return 1 * sortDir; } + return 0; + }); + } + function render(){ + var totalPages = Math.max(1, Math.ceil(view.length / pageSize)); + if (page > totalPages) { page = totalPages; } + var start = (page - 1) * pageSize; + var slice = view.slice(start, start + pageSize); + tbody.innerHTML = ''; + slice.forEach(function(r){ + var tr = document.createElement('tr'); + cols.forEach(function(c){ + var td = document.createElement('td'); + if (isNum(c)) { td.className = 'num'; } + td.textContent = r[c.field] == null ? '' : r[c.field]; + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + info.textContent = view.length + ' rows \u00b7 page ' + page + '/' + totalPages; + prev.disabled = page <= 1; + next.disabled = page >= totalPages; + } + search.addEventListener('input', function(){ applyFilter(); applySort(); render(); }); + prev.addEventListener('click', function(){ if (page > 1) { page--; render(); } }); + next.addEventListener('click', function(){ page++; render(); }); + buildHead(); render(); +})(); +'@ + + return "" +} diff --git a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateDbReport.ps1 b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateDbReport.ps1 new file mode 100644 index 0000000..adf100f --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateDbReport.ps1 @@ -0,0 +1,264 @@ +function Export-SPSUpdateDbReport { + <# + .SYNOPSIS + Generates a self-contained HTML report from a ContentDatabase inventory JSON. + + .DESCRIPTION + Export-SPSUpdateDbReport renders the ContentDatabase inventory produced by + Initialize-SPSContentDbJsonFile (the ---ContentDBs.json file, + with its SPContentDatabase1..4 sequence arrays) as a single, dependency-free + HTML file (no CDN, works offline on a SharePoint server). + + The report shows summary cards (total databases, total size, balance spread), + a per-sequence distribution view (count / size / percentage bars reflecting the + LPT balancing), and a sortable / filterable table of every database with its + sequence, server, web application URL and size. + + Each database entry is expected to expose Name, Server, WebAppUrl and (since + v4.1.0) SizeInMB / SizeInBytes. Inventories generated before v4.1.0 have no size + information; the report still renders and shows 'n/a' for the missing sizes. + + The data can be supplied either as a file (-InputFile, the JSON inventory) or as + an already-parsed object (-InputObject). Returns the path of the report written. + + .PARAMETER InputFile + Path of the ContentDatabase inventory JSON file to read. + + .PARAMETER InputObject + An already-parsed inventory object (with SPContentDatabase1..4 properties). + + .PARAMETER OutputFile + Destination path of the generated .html file. + + .PARAMETER Title + Heading shown at the top of the report. Defaults to a generic title. + + .PARAMETER EnvName + Environment label shown in the metadata line (e.g. PROD). + + .PARAMETER AppCode + Application code shown in the metadata line. + + .PARAMETER FarmName + Farm label shown in the metadata line. + + .PARAMETER Version + SPSUpdate version stamped in the report footer. Defaults to the module version. + + .EXAMPLE + Export-SPSUpdateDbReport -InputFile $json -OutputFile $html -EnvName 'PROD' -AppCode 'contoso' -FarmName 'CONTENT' + #> + [CmdletBinding(DefaultParameterSetName = 'ByFile')] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true, ParameterSetName = 'ByFile')] + [System.String] + $InputFile, + + [Parameter(Mandatory = $true, ParameterSetName = 'ByObject')] + $InputObject, + + [Parameter(Mandatory = $true)] + [System.String] + $OutputFile, + + [Parameter()] + [System.String] + $Title, + + [Parameter()] + [System.String] + $EnvName, + + [Parameter()] + [System.String] + $AppCode, + + [Parameter()] + [System.String] + $FarmName, + + [Parameter()] + [System.String] + $Version + ) + + # ---- Load the inventory ------------------------------------------------------- + if ($PSCmdlet.ParameterSetName -eq 'ByFile') { + if (-not (Test-Path -Path $InputFile)) { + throw "Export-SPSUpdateDbReport: input file not found: $InputFile" + } + $raw = Get-Content -Path $InputFile -Raw -Encoding UTF8 + $inventory = if ([string]::IsNullOrWhiteSpace($raw)) { $null } else { $raw | ConvertFrom-Json } + } + else { + $inventory = $InputObject + } + + if ([string]::IsNullOrEmpty($Title)) { $Title = 'SPSUpdate - ContentDatabase Inventory Report' } + + if ([string]::IsNullOrEmpty($Version)) { + $moduleVersion = (Get-Module -Name SPSUpdate.Common -ErrorAction SilentlyContinue).Version + $Version = if ($null -ne $moduleVersion) { $moduleVersion.ToString() } else { 'unknown' } + } + + # ---- Flatten the four sequence arrays into rows ------------------------------- + $sequenceNames = @('SPContentDatabase1', 'SPContentDatabase2', 'SPContentDatabase3', 'SPContentDatabase4') + $hasSize = $false + $rows = New-Object System.Collections.Generic.List[object] + $seqSummary = @() + + for ($s = 0; $s -lt $sequenceNames.Count; $s++) { + $seqProp = $sequenceNames[$s] + $seqLabel = "Sequence $($s + 1)" + $dbs = @() + if ($null -ne $inventory -and ($inventory.PSObject.Properties.Name -contains $seqProp)) { + $dbs = @($inventory.$seqProp) + } + + $seqCount = 0 + $seqBytes = [double]0 + foreach ($db in $dbs) { + if ($null -eq $db) { continue } + $seqCount++ + + $sizeMbValue = $null + if ($db.PSObject.Properties.Name -contains 'SizeInMB' -and $null -ne $db.SizeInMB -and "$($db.SizeInMB)" -ne '') { + $hasSize = $true + $sizeMbValue = [double]$db.SizeInMB + } + if ($db.PSObject.Properties.Name -contains 'SizeInBytes' -and $null -ne $db.SizeInBytes -and "$($db.SizeInBytes)" -ne '') { + $seqBytes += [double]$db.SizeInBytes + } + elseif ($null -ne $sizeMbValue) { + $seqBytes += $sizeMbValue * 1MB + } + + $rows.Add([PSCustomObject][ordered]@{ + Sequence = $seqLabel + Name = "$($db.Name)" + Server = "$($db.Server)" + WebAppUrl = "$($db.WebAppUrl)" + SizeMB = if ($null -ne $sizeMbValue) { $sizeMbValue } else { $null } + }) + } + + $seqSummary += [PSCustomObject]@{ + Name = $seqLabel + Count = $seqCount + Bytes = $seqBytes + SizeMB = [System.Math]::Round($seqBytes / 1MB, 0) + } + } + + $totalDbs = ($seqSummary | Measure-Object -Property Count -Sum).Sum + $totalBytes = ($seqSummary | Measure-Object -Property Bytes -Sum).Sum + $totalMB = [System.Math]::Round($totalBytes / 1MB, 0) + + # Percentage per sequence (by size when known, otherwise by count). + $distInput = foreach ($seq in $seqSummary) { + $pct = if ($hasSize -and $totalBytes -gt 0) { + [System.Math]::Round($seq.Bytes / $totalBytes * 100, 1) + } + elseif ($totalDbs -gt 0) { + [System.Math]::Round($seq.Count / $totalDbs * 100, 1) + } + else { 0 } + [PSCustomObject]@{ + Name = $seq.Name + Count = $seq.Count + SizeMB = $seq.SizeMB + Percent = $pct + } + } + + # Balance spread = max% - min% across sequences (lower is better). + $pcts = @($distInput | ForEach-Object { [double]$_.Percent }) + $spread = if ($pcts.Count -gt 0) { + [System.Math]::Round((($pcts | Measure-Object -Maximum).Maximum - ($pcts | Measure-Object -Minimum).Minimum), 1) + } + else { 0 } + + # ---- Summary cards ------------------------------------------------------------ + $cards = @( + (Get-SPSReportCardHtml -Value $totalDbs -Label 'Content databases') + (Get-SPSReportCardHtml -Value $(if ($hasSize) { '{0:N0}' -f $totalMB } else { 'n/a' }) -Label 'Total size (MB)') + (Get-SPSReportCardHtml -Value '4' -Label 'Sequences') + (Get-SPSReportCardHtml -Value $("{0:N1}%" -f $spread) -Label 'Balance spread' -Sub $(if ($hasSize) { 'by size' } else { 'by count' }) -Tone 'accent') + ) -join '' + + $distHtml = Get-SPSReportDistributionHtml -Sequences $distInput + $summaryInner = "
$cards
" + + "

Sequence distribution (LPT balancing)

$distHtml" + + # ---- Table payload ------------------------------------------------------------ + $columns = @( + @{ field = 'Sequence'; label = 'Sequence'; type = 'text' } + @{ field = 'Name'; label = 'Database'; type = 'text' } + @{ field = 'Server'; label = 'Server'; type = 'text' } + @{ field = 'WebAppUrl'; label = 'Web App'; type = 'text' } + @{ field = 'SizeMB'; label = 'Size (MB)'; type = 'num' } + ) + + $tableRows = foreach ($row in $rows) { + [PSCustomObject][ordered]@{ + Sequence = $row.Sequence + Name = $row.Name + Server = $row.Server + WebAppUrl = $row.WebAppUrl + SizeMB = if ($null -ne $row.SizeMB) { ('{0:N0}' -f [double]$row.SizeMB) } else { 'n/a' } + } + } + + $payload = [ordered]@{ + columns = $columns + rows = @($tableRows) + } + $json = $payload | ConvertTo-Json -Depth 5 -Compress + # Neutralize any sequence that could break out of the " + + (Get-SPSReportHtmlScript) + + '' + + $outDir = Split-Path -Path $OutputFile -Parent + if (-not [string]::IsNullOrEmpty($outDir) -and -not (Test-Path -Path $outDir)) { + $null = New-Item -Path $outDir -ItemType Directory -Force + } + Set-Content -Path $OutputFile -Value $html -Force -Encoding UTF8 + + return $OutputFile +} diff --git a/src/Modules/SPSUpdate.Common/Public/Initialize-SPSContentDbJsonFile.ps1 b/src/Modules/SPSUpdate.Common/Public/Initialize-SPSContentDbJsonFile.ps1 index 0e2e508..775f09d 100644 --- a/src/Modules/SPSUpdate.Common/Public/Initialize-SPSContentDbJsonFile.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Initialize-SPSContentDbJsonFile.ps1 @@ -22,6 +22,8 @@ [System.String]$Name [System.String]$Server [System.String]$WebAppUrl + [System.Int64]$SizeInBytes + [System.Double]$SizeInMB } #Get all content databases @@ -52,9 +54,11 @@ } } [void]$sequenceLists[$minIndex].Add([SPDbContent]@{ - Name = $spDatabase.Name; - Server = $spDatabase.Server; - WebAppUrl = $spDatabase.WebApplication.Url; + Name = $spDatabase.Name; + Server = $spDatabase.Server; + WebAppUrl = $spDatabase.WebApplication.Url; + SizeInBytes = [System.Int64]$spDatabase.DiskSizeRequired; + SizeInMB = [System.Math]::Round($spDatabase.DiskSizeRequired / 1MB, 0); }) $sequenceLoad[$minIndex] += $spDatabase.DiskSizeRequired } diff --git a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 index fe8c8bb..53f2a15 100644 --- a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 +++ b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'SPSUpdate.Common.psm1' - ModuleVersion = '4.0.0' + ModuleVersion = '4.1.0' GUID = 'd6f4e2b7-3a1c-4d8e-9f2a-6c5b7e0a1d34' Author = 'Jean-Cyril DROUHIN' CompanyName = 'luigilink' @@ -13,6 +13,7 @@ 'Add-SPSScheduledTask' 'Add-SPSUpdateEvent' 'Copy-SPSSideBySideFilesRemote' + 'Export-SPSUpdateDbReport' 'Get-SPSInstalledProductVersion' 'Get-SPSSecret' 'Get-SPSServersPatchStatus' From fd7c54755d287e7fc59b0e4db62832a9495d3113 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 16:08:19 +0200 Subject: [PATCH 2/4] feat: generate the ContentDB HTML report from SPSUpdate.ps1 Add a Results/ folder and a Write-SPSUpdateDbReport helper that renders the inventory HTML report via Export-SPSUpdateDbReport. It runs after the inventory JSON is (re)generated in both the InitContentDB action and the Default-mode prime, writing ---ContentDBs.html next to the Logs/Config folders. Report failures warn but never block the run. Ignore src/Results/ and generated *-ContentDBs.html runtime output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 4 +++- src/SPSUpdate.ps1 | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index afdaa2c..e4782dc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,9 @@ coverage.xml src/Config/*.psd1 !src/Config/*.example.psd1 -# Runtime output kept out of source control (logs and generated ContentDB inventory) +# Runtime output kept out of source control (logs, results and generated ContentDB inventory) src/Logs/ +src/Results/ **/*-ContentDBs.json **/*-ContentDBs_*.json +**/*-ContentDBs.html diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index e5088d0..b8b038c 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -194,11 +194,40 @@ $SPSUpdateVersion = (Get-Module -Name 'SPSUpdate.Common').Version.ToString() $getDateFormatted = Get-Date -Format yyyy-MM-dd_H-mm $spsUpdateFileName = "$($Application)-$($Environment)_$($getDateFormatted)" $spsUpdateDBsFile = "$($Application)-$($Environment)-$($spFarmName)-ContentDBs.json" +$spsUpdateDbReportFile = "$($Application)-$($Environment)-$($spFarmName)-ContentDBs.html" $currentUser = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name $pathLogsFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Logs' $pathConfigFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Config' +$pathResultsFolder = Join-Path -Path $PSScriptRoot -ChildPath 'Results' $fullScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'SPSUpdate.ps1' $spsUpdateDBsPath = Join-Path -Path $pathConfigFolder -ChildPath $spsUpdateDBsFile +$spsUpdateDbReportPath = Join-Path -Path $pathResultsFolder -ChildPath $spsUpdateDbReportFile + +# Local helper: (re)generate the ContentDatabase inventory HTML report from the JSON +# inventory, into the Results folder. Never blocks the run on a report failure. +function Write-SPSUpdateDbReport { + param( + [Parameter(Mandatory = $true)][System.String] $JsonPath, + [Parameter(Mandatory = $true)][System.String] $ReportPath + ) + try { + if (-not (Test-Path -Path $JsonPath)) { + return + } + if (-not (Test-Path -Path $pathResultsFolder)) { + New-Item -ItemType Directory -Path $pathResultsFolder -Force | Out-Null + } + $null = Export-SPSUpdateDbReport -InputFile $JsonPath ` + -OutputFile $ReportPath ` + -EnvName $Environment ` + -AppCode $Application ` + -FarmName $spFarmName + Write-Output "ContentDatabase inventory report generated: $ReportPath" + } + catch { + Write-Warning -Message "Failed to generate ContentDatabase inventory report: $($_.Exception.Message)" + } +} # Initialize logs if (-Not (Test-Path -Path $pathLogsFolder)) { @@ -287,6 +316,8 @@ try { "Initialize ContentDatabase json file for SPFARM: $($spFarmName)" Initialize-SPSContentDbJsonFile -Path $spsUpdateDBsPath $jsonDbCfg = Get-Content $spsUpdateDBsPath | ConvertFrom-Json + # Refresh the HTML inventory report alongside the freshly generated JSON. + Write-SPSUpdateDbReport -JsonPath $spsUpdateDBsPath -ReportPath $spsUpdateDbReportPath } } } @@ -316,6 +347,8 @@ switch ($Action) { Initialize-SPSContentDbJsonFile -Path $spsUpdateDBsPath if (Test-Path -Path $spsUpdateDBsPath) { Write-Output "ContentDatabase json file generated successfully: $spsUpdateDBsPath" + # Generate the self-contained HTML inventory report. + Write-SPSUpdateDbReport -JsonPath $spsUpdateDBsPath -ReportPath $spsUpdateDbReportPath } else { throw "ContentDatabase json file was not created: $spsUpdateDBsPath" From 1bb555a64efbdae565fe7b775d78cd697ccb0c3f Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 16:10:08 +0200 Subject: [PATCH 3/4] test: cover the ContentDB report + split report asset helpers - Split the multi-function Get-SPSReportAssets.ps1 into one-function-per-file private helpers (Get-SPSReportCardHtml, Get-SPSReportHtmlHead, Get-SPSReportDistributionHtml, Get-SPSReportHtmlScript) to honour the module's one-function-per-file convention. - Add Export-SPSUpdateDbReport.Tests.ps1: self-contained HTML output, total size + distribution bars, JSON-payload markup neutralization, every row embedded, the legacy no-size inventory path (n/a + distribute by count), reading from a JSON file, and the missing-file error. - Update the module test: expect Export-SPSUpdateDbReport in the public set and the new private helpers hidden. PSScriptAnalyzer clean; 130 Pester tests passing (1 Windows-only skipped). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Private/Get-SPSReportAssets.ps1 | 210 ------------------ .../Private/Get-SPSReportCardHtml.ps1 | 33 +++ .../Private/Get-SPSReportDistributionHtml.ps1 | 25 +++ .../Private/Get-SPSReportHtmlHead.ps1 | 50 +++++ .../Private/Get-SPSReportHtmlScript.ps1 | 99 +++++++++ tests/Export-SPSUpdateDbReport.Tests.ps1 | 112 ++++++++++ tests/SPSUpdate.Common.Tests.ps1 | 5 +- 7 files changed, 323 insertions(+), 211 deletions(-) delete mode 100644 src/Modules/SPSUpdate.Common/Private/Get-SPSReportAssets.ps1 create mode 100644 src/Modules/SPSUpdate.Common/Private/Get-SPSReportCardHtml.ps1 create mode 100644 src/Modules/SPSUpdate.Common/Private/Get-SPSReportDistributionHtml.ps1 create mode 100644 src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 create mode 100644 src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlScript.ps1 create mode 100644 tests/Export-SPSUpdateDbReport.Tests.ps1 diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportAssets.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportAssets.ps1 deleted file mode 100644 index 2494f09..0000000 --- a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportAssets.ps1 +++ /dev/null @@ -1,210 +0,0 @@ -function Get-SPSReportCardHtml { - <# - .SYNOPSIS - Builds the HTML for one summary "card" (a big number plus a label). - #> - [CmdletBinding()] - [OutputType([System.String])] - param - ( - [Parameter(Mandatory = $true)] - $Value, - - [Parameter(Mandatory = $true)] - [System.String] - $Label, - - [Parameter()] - [System.String] - $Sub = '', - - [Parameter()] - [ValidateSet('', 'accent')] - [System.String] - $Tone = '' - ) - - $encValue = ConvertTo-SPSHtmlEncoded -Value ("$Value") - $encLabel = ConvertTo-SPSHtmlEncoded -Value $Label - $encSub = ConvertTo-SPSHtmlEncoded -Value $Sub - $toneClass = if ([string]::IsNullOrEmpty($Tone)) { '' } else { " $Tone" } - $subHtml = if ([string]::IsNullOrEmpty($encSub)) { '' } else { "
$encSub
" } - return "
$encValue
$encLabel
$subHtml
" -} - -function Get-SPSReportHtmlHead { - <# - .SYNOPSIS - Returns the document head (with the embedded stylesheet) and the opening body tag. - #> - [CmdletBinding()] - [OutputType([System.String])] - param - ( - [Parameter(Mandatory = $true)] - [System.String] - $Title - ) - - $css = @' -:root{--brand:#1f6fb2;--brand-dark:#155a91;--ink:#222;--muted:#666;--line:#e3e3e3;--zebra:#f7f9fb;--bar:#1f6fb2;--bar-bg:#e8eef5} -*{box-sizing:border-box} -body{font-family:'Aptos','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;color:var(--ink);margin:0;padding:24px;background:#fff} -h1{color:var(--brand);font-size:22px;margin:0 0 4px} -h2{color:var(--brand);font-size:16px;margin:24px 0 8px;border-bottom:2px solid var(--brand);padding-bottom:4px} -h3{color:var(--brand-dark);font-size:13px;margin:0 0 6px} -.meta{color:var(--muted);font-size:12px;margin-bottom:16px} -.summary{background:#eef5fb;border:1px solid #cfe0ef;border-left:4px solid var(--brand);border-radius:6px;padding:16px;margin-bottom:8px} -.cards{display:flex;flex-wrap:wrap;gap:12px} -.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 16px;min-width:120px} -.card-value{font-size:24px;font-weight:700;color:var(--brand)} -.card-label{font-size:12px;color:var(--muted)} -.card-sub{font-size:11px;color:var(--muted);margin-top:2px} -.card.accent{background:#eef5fb;border-color:#cfe0ef} -.dist{margin-top:14px} -.dist-row{display:flex;align-items:center;gap:10px;margin:6px 0;font-size:12px} -.dist-name{width:90px;color:var(--brand-dark);font-weight:600} -.dist-track{flex:1;background:var(--bar-bg);border-radius:4px;height:16px;overflow:hidden} -.dist-fill{background:var(--bar);height:100%} -.dist-val{width:170px;text-align:right;color:var(--muted)} -table{border-collapse:collapse;width:100%;font-size:12px} -th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top} -th{background:var(--brand);color:#fff;cursor:pointer;user-select:none;position:sticky;top:0} -td.num,th.num{text-align:right} -tbody tr:nth-child(even){background:var(--zebra)} -.controls{display:flex;justify-content:space-between;align-items:center;margin:12px 0;flex-wrap:wrap;gap:8px} -.search{padding:6px 10px;border:1px solid var(--line);border-radius:4px;font-size:13px;width:280px;max-width:100%} -.pager{display:flex;gap:8px;align-items:center;font-size:12px} -.pager button{padding:4px 10px;border:1px solid var(--line);background:#fff;border-radius:4px;cursor:pointer} -.pager button:disabled{opacity:.4;cursor:default} -.footer{color:var(--muted);font-size:11px;margin-top:24px;border-top:1px solid var(--line);padding-top:8px} -'@ - - return "$Title" -} - -function Get-SPSReportDistributionHtml { - <# - .SYNOPSIS - Builds the per-sequence distribution bars (count / MB / percentage). - #> - [CmdletBinding()] - [OutputType([System.String])] - param - ( - [Parameter(Mandatory = $true)] - [System.Object[]] - $Sequences - ) - - $rowsHtml = '' - foreach ($seq in $Sequences) { - $pct = [double]$seq.Percent - $encName = ConvertTo-SPSHtmlEncoded -Value $seq.Name - $valText = '{0} db · {1:N0} MB · {2:N1}%' -f $seq.Count, $seq.SizeMB, $pct - $rowsHtml += "
$encName
" + - "
" + - "
$valText
" - } - return "
$rowsHtml
" -} - -function Get-SPSReportHtmlScript { - <# - .SYNOPSIS - Returns the vanilla-JavaScript block that renders the interactive table. - #> - [CmdletBinding()] - [OutputType([System.String])] - param () - - $js = @' -(function(){ - var node = document.getElementById('spsReportData'); - var data = JSON.parse(node.textContent || node.innerText); - var cols = data.columns || []; - var rows = data.rows || []; - var pageSize = 50, page = 1, sortField = null, sortDir = 1, view = rows; - var search = document.getElementById('spsSearch'); - var thead = document.getElementById('spsThead'); - var tbody = document.getElementById('spsTbody'); - var info = document.getElementById('spsPageInfo'); - var prev = document.getElementById('spsPrev'); - var next = document.getElementById('spsNext'); - - function isNum(c){ return c.type === 'num'; } - function buildHead(){ - var tr = document.createElement('tr'); - cols.forEach(function(c){ - var th = document.createElement('th'); - if (isNum(c)) { th.className = 'num'; } - th.textContent = c.label + ' \u2195'; - th.addEventListener('click', function(){ - if (sortField === c.field) { sortDir = -sortDir; } else { sortField = c.field; sortDir = 1; } - applySort(); render(); - }); - tr.appendChild(th); - }); - thead.appendChild(tr); - } - function applyFilter(){ - var q = (search.value || '').trim().toLowerCase(); - if (!q) { view = rows; } - else { - view = rows.filter(function(r){ - return cols.some(function(c){ - var v = r[c.field]; - return v != null && String(v).toLowerCase().indexOf(q) !== -1; - }); - }); - } - page = 1; - } - function applySort(){ - if (!sortField) { return; } - var col = null; - cols.forEach(function(c){ if (c.field === sortField) { col = c; } }); - var numeric = col && isNum(col); - view = view.slice().sort(function(a,b){ - var x = a[sortField], y = b[sortField]; - if (numeric) { - x = parseFloat(x); y = parseFloat(y); - if (isNaN(x)) { x = -Infinity; } if (isNaN(y)) { y = -Infinity; } - return (x - y) * sortDir; - } - x = x == null ? '' : String(x).toLowerCase(); - y = y == null ? '' : String(y).toLowerCase(); - if (x < y) { return -1 * sortDir; } - if (x > y) { return 1 * sortDir; } - return 0; - }); - } - function render(){ - var totalPages = Math.max(1, Math.ceil(view.length / pageSize)); - if (page > totalPages) { page = totalPages; } - var start = (page - 1) * pageSize; - var slice = view.slice(start, start + pageSize); - tbody.innerHTML = ''; - slice.forEach(function(r){ - var tr = document.createElement('tr'); - cols.forEach(function(c){ - var td = document.createElement('td'); - if (isNum(c)) { td.className = 'num'; } - td.textContent = r[c.field] == null ? '' : r[c.field]; - tr.appendChild(td); - }); - tbody.appendChild(tr); - }); - info.textContent = view.length + ' rows \u00b7 page ' + page + '/' + totalPages; - prev.disabled = page <= 1; - next.disabled = page >= totalPages; - } - search.addEventListener('input', function(){ applyFilter(); applySort(); render(); }); - prev.addEventListener('click', function(){ if (page > 1) { page--; render(); } }); - next.addEventListener('click', function(){ page++; render(); }); - buildHead(); render(); -})(); -'@ - - return "" -} diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportCardHtml.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportCardHtml.ps1 new file mode 100644 index 0000000..b35748c --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportCardHtml.ps1 @@ -0,0 +1,33 @@ +function Get-SPSReportCardHtml { + <# + .SYNOPSIS + Builds the HTML for one summary "card" (a big number plus a label). + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + $Value, + + [Parameter(Mandatory = $true)] + [System.String] + $Label, + + [Parameter()] + [System.String] + $Sub = '', + + [Parameter()] + [ValidateSet('', 'accent')] + [System.String] + $Tone = '' + ) + + $encValue = ConvertTo-SPSHtmlEncoded -Value ("$Value") + $encLabel = ConvertTo-SPSHtmlEncoded -Value $Label + $encSub = ConvertTo-SPSHtmlEncoded -Value $Sub + $toneClass = if ([string]::IsNullOrEmpty($Tone)) { '' } else { " $Tone" } + $subHtml = if ([string]::IsNullOrEmpty($encSub)) { '' } else { "
$encSub
" } + return "
$encValue
$encLabel
$subHtml
" +} diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportDistributionHtml.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportDistributionHtml.ps1 new file mode 100644 index 0000000..8270bf0 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportDistributionHtml.ps1 @@ -0,0 +1,25 @@ +function Get-SPSReportDistributionHtml { + <# + .SYNOPSIS + Builds the per-sequence distribution bars (count / MB / percentage). + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.Object[]] + $Sequences + ) + + $rowsHtml = '' + foreach ($seq in $Sequences) { + $pct = [double]$seq.Percent + $encName = ConvertTo-SPSHtmlEncoded -Value $seq.Name + $valText = '{0} db · {1:N0} MB · {2:N1}%' -f $seq.Count, $seq.SizeMB, $pct + $rowsHtml += "
$encName
" + + "
" + + "
$valText
" + } + return "
$rowsHtml
" +} diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 new file mode 100644 index 0000000..d5118b2 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 @@ -0,0 +1,50 @@ +function Get-SPSReportHtmlHead { + <# + .SYNOPSIS + Returns the document head (with the embedded stylesheet) and the opening body tag. + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Title + ) + + $css = @' +:root{--brand:#1f6fb2;--brand-dark:#155a91;--ink:#222;--muted:#666;--line:#e3e3e3;--zebra:#f7f9fb;--bar:#1f6fb2;--bar-bg:#e8eef5} +*{box-sizing:border-box} +body{font-family:'Aptos','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;color:var(--ink);margin:0;padding:24px;background:#fff} +h1{color:var(--brand);font-size:22px;margin:0 0 4px} +h2{color:var(--brand);font-size:16px;margin:24px 0 8px;border-bottom:2px solid var(--brand);padding-bottom:4px} +h3{color:var(--brand-dark);font-size:13px;margin:0 0 6px} +.meta{color:var(--muted);font-size:12px;margin-bottom:16px} +.summary{background:#eef5fb;border:1px solid #cfe0ef;border-left:4px solid var(--brand);border-radius:6px;padding:16px;margin-bottom:8px} +.cards{display:flex;flex-wrap:wrap;gap:12px} +.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 16px;min-width:120px} +.card-value{font-size:24px;font-weight:700;color:var(--brand)} +.card-label{font-size:12px;color:var(--muted)} +.card-sub{font-size:11px;color:var(--muted);margin-top:2px} +.card.accent{background:#eef5fb;border-color:#cfe0ef} +.dist{margin-top:14px} +.dist-row{display:flex;align-items:center;gap:10px;margin:6px 0;font-size:12px} +.dist-name{width:90px;color:var(--brand-dark);font-weight:600} +.dist-track{flex:1;background:var(--bar-bg);border-radius:4px;height:16px;overflow:hidden} +.dist-fill{background:var(--bar);height:100%} +.dist-val{width:170px;text-align:right;color:var(--muted)} +table{border-collapse:collapse;width:100%;font-size:12px} +th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top} +th{background:var(--brand);color:#fff;cursor:pointer;user-select:none;position:sticky;top:0} +td.num,th.num{text-align:right} +tbody tr:nth-child(even){background:var(--zebra)} +.controls{display:flex;justify-content:space-between;align-items:center;margin:12px 0;flex-wrap:wrap;gap:8px} +.search{padding:6px 10px;border:1px solid var(--line);border-radius:4px;font-size:13px;width:280px;max-width:100%} +.pager{display:flex;gap:8px;align-items:center;font-size:12px} +.pager button{padding:4px 10px;border:1px solid var(--line);background:#fff;border-radius:4px;cursor:pointer} +.pager button:disabled{opacity:.4;cursor:default} +.footer{color:var(--muted);font-size:11px;margin-top:24px;border-top:1px solid var(--line);padding-top:8px} +'@ + + return "$Title" +} diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlScript.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlScript.ps1 new file mode 100644 index 0000000..20db9f3 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlScript.ps1 @@ -0,0 +1,99 @@ +function Get-SPSReportHtmlScript { + <# + .SYNOPSIS + Returns the vanilla-JavaScript block that renders the interactive table. + #> + [CmdletBinding()] + [OutputType([System.String])] + param () + + $js = @' +(function(){ + var node = document.getElementById('spsReportData'); + var data = JSON.parse(node.textContent || node.innerText); + var cols = data.columns || []; + var rows = data.rows || []; + var pageSize = 50, page = 1, sortField = null, sortDir = 1, view = rows; + var search = document.getElementById('spsSearch'); + var thead = document.getElementById('spsThead'); + var tbody = document.getElementById('spsTbody'); + var info = document.getElementById('spsPageInfo'); + var prev = document.getElementById('spsPrev'); + var next = document.getElementById('spsNext'); + + function isNum(c){ return c.type === 'num'; } + function buildHead(){ + var tr = document.createElement('tr'); + cols.forEach(function(c){ + var th = document.createElement('th'); + if (isNum(c)) { th.className = 'num'; } + th.textContent = c.label + ' \u2195'; + th.addEventListener('click', function(){ + if (sortField === c.field) { sortDir = -sortDir; } else { sortField = c.field; sortDir = 1; } + applySort(); render(); + }); + tr.appendChild(th); + }); + thead.appendChild(tr); + } + function applyFilter(){ + var q = (search.value || '').trim().toLowerCase(); + if (!q) { view = rows; } + else { + view = rows.filter(function(r){ + return cols.some(function(c){ + var v = r[c.field]; + return v != null && String(v).toLowerCase().indexOf(q) !== -1; + }); + }); + } + page = 1; + } + function applySort(){ + if (!sortField) { return; } + var col = null; + cols.forEach(function(c){ if (c.field === sortField) { col = c; } }); + var numeric = col && isNum(col); + view = view.slice().sort(function(a,b){ + var x = a[sortField], y = b[sortField]; + if (numeric) { + x = parseFloat(x); y = parseFloat(y); + if (isNaN(x)) { x = -Infinity; } if (isNaN(y)) { y = -Infinity; } + return (x - y) * sortDir; + } + x = x == null ? '' : String(x).toLowerCase(); + y = y == null ? '' : String(y).toLowerCase(); + if (x < y) { return -1 * sortDir; } + if (x > y) { return 1 * sortDir; } + return 0; + }); + } + function render(){ + var totalPages = Math.max(1, Math.ceil(view.length / pageSize)); + if (page > totalPages) { page = totalPages; } + var start = (page - 1) * pageSize; + var slice = view.slice(start, start + pageSize); + tbody.innerHTML = ''; + slice.forEach(function(r){ + var tr = document.createElement('tr'); + cols.forEach(function(c){ + var td = document.createElement('td'); + if (isNum(c)) { td.className = 'num'; } + td.textContent = r[c.field] == null ? '' : r[c.field]; + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + info.textContent = view.length + ' rows \u00b7 page ' + page + '/' + totalPages; + prev.disabled = page <= 1; + next.disabled = page >= totalPages; + } + search.addEventListener('input', function(){ applyFilter(); applySort(); render(); }); + prev.addEventListener('click', function(){ if (page > 1) { page--; render(); } }); + next.addEventListener('click', function(){ page++; render(); }); + buildHead(); render(); +})(); +'@ + + return "" +} diff --git a/tests/Export-SPSUpdateDbReport.Tests.ps1 b/tests/Export-SPSUpdateDbReport.Tests.ps1 new file mode 100644 index 0000000..679639a --- /dev/null +++ b/tests/Export-SPSUpdateDbReport.Tests.ps1 @@ -0,0 +1,112 @@ +# Tests for the ContentDatabase inventory HTML report (Export-SPSUpdateDbReport). +# Cross-platform: pure string/HTML generation, no SharePoint dependency. + +BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $modulePath = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1' + Import-Module -Name $modulePath -Force + + $script:tmpDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("spsupd-report-" + [guid]::NewGuid()) + New-Item -Path $script:tmpDir -ItemType Directory -Force | Out-Null + + # Inventory WITH size (v4.1.0+ shape) + $script:invWithSize = [pscustomobject]@{ + SPContentDatabase1 = @([pscustomobject]@{ Name = 'DB_A'; Server = 'SQL1'; WebAppUrl = 'https://intranet'; SizeInBytes = 3221225472; SizeInMB = 3072 }) + SPContentDatabase2 = @( + [pscustomobject]@{ Name = 'DB_B'; Server = 'SQL1'; WebAppUrl = 'https://mysite'; SizeInBytes = 1073741824; SizeInMB = 1024 } + [pscustomobject]@{ Name = 'DB_C & "y"'; Server = 'SQL2'; WebAppUrl = 'https://extranet'; SizeInBytes = 2147483648; SizeInMB = 2048 } + ) + SPContentDatabase3 = @([pscustomobject]@{ Name = 'DB_D'; Server = 'SQL2'; WebAppUrl = 'https://intranet'; SizeInBytes = 2684354560; SizeInMB = 2560 }) + SPContentDatabase4 = @([pscustomobject]@{ Name = 'DB_E'; Server = 'SQL1'; WebAppUrl = 'https://intranet'; SizeInBytes = 2952790016; SizeInMB = 2816 }) + } + + # Legacy inventory WITHOUT size (pre-4.1.0 shape) + $script:invNoSize = [pscustomobject]@{ + SPContentDatabase1 = @([pscustomobject]@{ Name = 'OLD_A'; Server = 'SQL1'; WebAppUrl = 'https://intranet' }) + SPContentDatabase2 = @([pscustomobject]@{ Name = 'OLD_B'; Server = 'SQL1'; WebAppUrl = 'https://mysite' }) + SPContentDatabase3 = @() + SPContentDatabase4 = @() + } +} + +AfterAll { + if ($script:tmpDir -and (Test-Path $script:tmpDir)) { + Remove-Item -Path $script:tmpDir -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-Module -Name SPSUpdate.Common -Force -ErrorAction SilentlyContinue +} + +Describe 'Export-SPSUpdateDbReport (with size)' { + BeforeAll { + $script:outWith = Join-Path -Path $script:tmpDir -ChildPath 'with-size.html' + $script:returned = Export-SPSUpdateDbReport -InputObject $script:invWithSize -OutputFile $script:outWith ` + -EnvName 'PROD' -AppCode 'contoso' -FarmName 'CONTENT' -Version '4.1.0' + $script:htmlWith = Get-Content -Path $script:outWith -Raw + } + + It 'returns the output path and writes the file' { + $script:returned | Should -Be $script:outWith + Test-Path $script:outWith | Should -BeTrue + } + + It 'produces a self-contained HTML document (no external resources)' { + $script:htmlWith | Should -Match '' + $script:htmlWith | Should -Match '