Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@
The format is based on and uses the types of changes according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.1.0] - 2026-06-29

### Added

- New public function `Export-SPSUpdateDbReport` that renders the ContentDatabase inventory (`<App>-<Env>-<Farm>-ContentDBs.json`) as a self-contained, offline HTML report: summary cards (total databases, total size, balance spread), the per-sequence LPT distribution (count / size / percentage bars), and a sortable, filterable table of every database. Inventories generated before v4.1.0 (no size) still render and fall back to distributing by database count.
- `SPSUpdate.ps1` now writes the HTML report under a new `Results\` folder whenever the inventory is (re)generated (the `InitContentDB` action and the Default-mode prime). Report failures warn but never block the run.
- Private report helpers: `ConvertTo-SPSHtmlEncoded`, `Get-SPSReportCardHtml`, `Get-SPSReportHtmlHead`, `Get-SPSReportDistributionHtml`, `Get-SPSReportHtmlScript`.

### Changed

- `Initialize-SPSContentDbJsonFile` now persists `SizeInBytes` and `SizeInMB` for each database in the inventory JSON. This is backward compatible: the mount/upgrade flow still reads only `Name`, `WebAppUrl` and `Server`.
- Bumped the module manifest to `4.1.0` and exported `Export-SPSUpdateDbReport`.

### Tests

- Added `Export-SPSUpdateDbReport.Tests.ps1` covering the self-contained HTML output, total size and distribution rendering, JSON-payload markup neutralization, the legacy no-size inventory path, reading from a JSON file, and the missing-file error.

## [4.0.0] - 2026-06-29

### Added
Expand Down
42 changes: 11 additions & 31 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,24 @@
# SPSUpdate - Release Notes

## [4.0.0] - 2026-06-29
## [4.1.0] - 2026-06-29

This is a major modernization release that aligns SPSUpdate with the SPSWeather and
SPSUserSync projects. It is a **breaking** release: the package layout, the configuration
format and the credential storage all change. Review the migration notes below before
upgrading an existing deployment.
This release adds a self-contained HTML report for the ContentDatabase inventory, in the
same spirit as the SPSUserSync reports. It builds on the v4.0.0 modernization and is fully
backward compatible.

### Added

- New `SPSUpdate.Common` PowerShell module (`src/Modules/SPSUpdate.Common`) with a manifest-driven version, a dot-sourcing loader, and a one-file-per-function layout split into `Public/` and `Private/`.
- DPAPI credential store: `Get-SPSSecret` / `Set-SPSSecret` persist the `InstallAccount` as an encrypted SecureString in `Config\secrets.psd1`, replacing the Windows Credential Manager module. `-Action Install` writes the secret and `-Action Uninstall` removes it.
- Tolerant configuration loader: optional keys fall back to safe defaults (`Binaries.ProductUpdate`, `Binaries.ShutdownServices` and `UpgradeContentDatabase` default to `$true`; `MountContentDatabase` and `SideBySideToken.Enable` default to `$false`).
- Repository scaffolding aligned with the other SPS* projects: `.editorconfig`, `.gitattributes` (UTF-8 BOM + CRLF for PowerShell files), `PSScriptAnalyzerSettings.psd1`, and an expanded `.gitignore`.
- Wiki: new `Release-Process` page and `_Sidebar.md` navigation.
- Offline install guide (`src/SPSUpdate_README.md`) shipped inside the release ZIP, rewritten for the psd1 config, the DPAPI secret store and the `Modules\` layout (no DSC).
- New public function `Export-SPSUpdateDbReport` that renders the ContentDatabase inventory (`<App>-<Env>-<Farm>-ContentDBs.json`) as a self-contained, offline HTML report: summary cards (total databases, total size, balance spread), the per-sequence LPT distribution (count / size / percentage bars), and a sortable, filterable table of every database. Inventories generated before v4.1.0 (no size) still render and fall back to distributing by database count.
- `SPSUpdate.ps1` now writes the HTML report under a new `Results\` folder whenever the inventory is (re)generated (the `InitContentDB` action and the Default-mode prime). Report failures warn but never block the run.
- Private report helpers: `ConvertTo-SPSHtmlEncoded`, `Get-SPSReportCardHtml`, `Get-SPSReportHtmlHead`, `Get-SPSReportDistributionHtml`, `Get-SPSReportHtmlScript`.

### Changed

- **BREAKING** — Project layout moved from `scripts/` to `src/`. The release ZIP now packages the *contents* of `src/`, so the archive extracts straight to `SPSUpdate.ps1`, `Config\` and `Modules\` (no wrapper folder).
- **BREAKING** — Environment configuration moved from JSON to a PowerShell data file (`*.psd1`), read with `Import-PowerShellDataFile`. A single documented template (`Config\CONTOSO-PROD.example.psd1`) lists every option with its possible values inline. Runtime/output files (the ContentDB inventory and logs) stay JSON by design.
- **BREAKING** — The config key `StoredCredential` is renamed to `CredentialKey` and now points at an entry in `Config\secrets.psd1` instead of a Windows Credential Manager target.
- `SPSUpdate.ps1` now imports `SPSUpdate.Common`, derives its version banner from the module manifest, and reads/writes the InstallAccount credential through `Get-SPSSecret`/`Set-SPSSecret`.
- `Add-SPSUpdateEvent` is now self-contained (version/user resolved from the module) and re-points a mis-mapped event source to the `SPSUpdate` log instead of returning silently.
- CI: `release.yml` zips the contents of `src/`; `pester.yml` triggers on `src/**`, `tests/**` and `PSScriptAnalyzerSettings.psd1`, and runs PSScriptAnalyzer against `src/SPSUpdate.ps1` and the `SPSUpdate.Common` module.
- `README.md` trimmed to a short overview with quick links; the wiki (`Home`, `Getting-Started`, `Configuration`, `Usage`) rewritten for the new layout, psd1 config and DPAPI secret store.
- `Initialize-SPSContentDbJsonFile` now persists `SizeInBytes` and `SizeInMB` for each database in the inventory JSON. This is backward compatible: the mount/upgrade flow still reads only `Name`, `WebAppUrl` and `Server`.
- Bumped the module manifest to `4.1.0` and exported `Export-SPSUpdateDbReport`.

### Fixed
### Notes

- `Invoke-SPSCommand` now fails fast when the CredSSP `New-PSSession` cannot be established (`-ErrorAction Stop` + a clear `throw`) instead of falling back to running the SharePoint scriptblock **locally** on the master server with no session — which previously made PSConfig and side-by-side operations silently target the wrong server. Added an `OpenTimeout` and clearer remote-failure error messages, aligned with the SPSWeather hardening.

### Removed

- **BREAKING** — The bundled third-party `credentialmanager` module (DLLs + manifest) and all `Get-/New-/Remove-StoredCredential` usage.
- The legacy `scripts/` tree, including the flat `util.psm1` / `sps.util.psm1` helpers and the per-farm JSON configs.

### Migration from 3.x

1. Convert each JSON config to a `*.psd1` file (see `Config\CONTOSO-PROD.example.psd1`) and rename `StoredCredential` to `CredentialKey`.
2. Re-run `.\SPSUpdate.ps1 -ConfigFile '<farm>.psd1' -Action Install -InstallAccount (Get-Credential)` **as the service account** to store the credential in `Config\secrets.psd1` (the previous Credential Manager entry is no longer used).
3. Extract the new ZIP on each server; it unpacks to `SPSUpdate.ps1`, `Config\` and `Modules\` directly.
- An inventory produced by v4.0.0 (without size) renders correctly; regenerate it with `-Action InitContentDB` to get the size columns and size-based balancing in the report.

A full list of changes in each version can be found in the [change log](CHANGELOG.md)
50 changes: 50 additions & 0 deletions src/Modules/SPSUpdate.Common/Private/ConvertTo-SPSHtmlEncoded.ps1
Original file line number Diff line number Diff line change
@@ -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 <prod> & "co"'
# DB &lt;prod&gt; &amp; &quot;co&quot;
#>
[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('&amp;') }
'<' { [void]$sb.Append('&lt;') }
'>' { [void]$sb.Append('&gt;') }
'"' { [void]$sb.Append('&quot;') }
"'" { [void]$sb.Append('&#39;') }
default { [void]$sb.Append($char) }
}
}
return $sb.ToString()
}
}
33 changes: 33 additions & 0 deletions src/Modules/SPSUpdate.Common/Private/Get-SPSReportCardHtml.ps1
Original file line number Diff line number Diff line change
@@ -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 { "<div class=`"card-sub`">$encSub</div>" }
return "<div class=`"card$toneClass`"><div class=`"card-value`">$encValue</div><div class=`"card-label`">$encLabel</div>$subHtml</div>"
}
Original file line number Diff line number Diff line change
@@ -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 &middot; {1:N0} MB &middot; {2:N1}%' -f $seq.Count, $seq.SizeMB, $pct
$rowsHtml += "<div class=`"dist-row`"><div class=`"dist-name`">$encName</div>" +
"<div class=`"dist-track`"><div class=`"dist-fill`" style=`"width:$([System.Math]::Round($pct,1))%`"></div></div>" +
"<div class=`"dist-val`">$valText</div></div>"
}
return "<div class=`"dist`">$rowsHtml</div>"
}
50 changes: 50 additions & 0 deletions src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1
Original file line number Diff line number Diff line change
@@ -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 "<!DOCTYPE html><html lang=`"en`"><head><meta charset=`"utf-8`"><meta name=`"viewport`" content=`"width=device-width, initial-scale=1`"><title>$Title</title><style>$css</style></head><body>"
}
99 changes: 99 additions & 0 deletions src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlScript.ps1
Original file line number Diff line number Diff line change
@@ -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 "<script>$js</script>"
}
Loading
Loading