From d8db8dd9b943f1c72e5125521d06f7c1c710bfee Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 16:31:39 +0200 Subject: [PATCH 01/15] feat: add StatusStorePath config and Test-SPSUpdateReadiness pre-flight Groundwork for the v4.2.0 near-real-time patching dashboard: - Add an optional StatusStorePath (UNC share) to the environment config; the loader defaults it to '' (fall back to the local Results\status folder). - New Test-SPSUpdateReadiness.ps1 (modelled on Test-SPSWeatherReadiness): validates the module import, config keys, the DPAPI secret, elevation, the status store reachability + write access (temporary probe file), and the per-server WinRM/CredSSP reachability. Read-only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Config/CONTOSO-PROD.example.psd1 | 8 + src/SPSUpdate.ps1 | 5 + src/Test-SPSUpdateReadiness.ps1 | 272 +++++++++++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 src/Test-SPSUpdateReadiness.ps1 diff --git a/src/Config/CONTOSO-PROD.example.psd1 b/src/Config/CONTOSO-PROD.example.psd1 index c307dd9..b9e297a 100644 --- a/src/Config/CONTOSO-PROD.example.psd1 +++ b/src/Config/CONTOSO-PROD.example.psd1 @@ -39,6 +39,14 @@ # Possible values : any key present in secrets.psd1, e.g. 'PROD-ADM'. CredentialKey = 'PROD-ADM' + # StatusStorePath : OPTIONAL UNC share where every server writes its patching + # progress so the live HTML dashboard can be assembled (near real-time tracking, + # v4.2.0+). It must be writable by the InstallAccount from every farm server. + # Leave empty/omit to fall back to the local Results\status folder (in that case + # ProductUpdate runs on other servers are not captured in the master dashboard). + # Possible values : '' or a UNC path, e.g. '\\fileserver\spsupdate-status'. + StatusStorePath = '\\fileserver\spsupdate-status' + # --- Binaries (REQUIRED block; used by -Action ProductUpdate) --------------------- Binaries = @{ # ProductUpdate : allow the binary installation step. diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index b8b038c..0add91d 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -172,6 +172,11 @@ function Get-SPSUpdateConfiguration { $config.SideBySideToken.BuildVersion = '' } + # StatusStorePath is optional; empty string means "use the local Results\status folder". + if (-not $config.ContainsKey('StatusStorePath') -or $null -eq $config.StatusStorePath) { + $config.StatusStorePath = '' + } + return $config } diff --git a/src/Test-SPSUpdateReadiness.ps1 b/src/Test-SPSUpdateReadiness.ps1 new file mode 100644 index 0000000..a6b6c55 --- /dev/null +++ b/src/Test-SPSUpdateReadiness.ps1 @@ -0,0 +1,272 @@ +<# + .SYNOPSIS + Pre-flight readiness check for SPSUpdate. + + .DESCRIPTION + Validates, before running SPSUpdate.ps1, that the environment is ready: + the SPSUpdate.Common module imports, the environment configuration parses + and exposes the required keys, the service credential exists in secrets.psd1 + and decrypts under the current account (DPAPI), the session is elevated, the + status store (UNC share, v4.2.0+) is writable, and each farm server is + reachable for CredSSP remoting. + + Read-only: it never changes configuration, credentials or the farm. The only + side effect is a temporary probe file written and immediately deleted in the + status store to confirm it is writable. + + .PARAMETER ConfigFile + Path to the environment configuration .psd1 file (same one passed to + SPSUpdate.ps1). secrets.psd1 is looked up in the same folder. + + .PARAMETER SkipNetwork + Skip the per-server WinRM/CredSSP reachability probe (useful off-server). + + .PARAMETER SkipSharePoint + Skip enumerating the farm servers via Get-SPServer. + + .EXAMPLE + .\Test-SPSUpdateReadiness.ps1 -ConfigFile 'Config\CONTOSO-PROD-CONTENT.psd1' + + .NOTES + FileName: Test-SPSUpdateReadiness.ps1 + Author: luigilink (Jean-Cyril DROUHIN) + Project: https://github.com/luigilink/SPSUpdate +#> + +#Requires -Version 5.1 + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'This is an interactive, operator-facing readiness tool whose purpose is colored console output.')] +[CmdletBinding()] +param +( + [Parameter(Mandatory = $true)] + [System.String] + $ConfigFile, + + [Parameter()] + [switch] + $SkipNetwork, + + [Parameter()] + [switch] + $SkipSharePoint, + + [Parameter()] + [System.Int32] + $TimeoutSeconds = 5 +) + +$script:results = New-Object System.Collections.Generic.List[object] + +function Add-CheckResult { + param + ( + [Parameter(Mandatory = $true)] [System.String] $Section, + [Parameter(Mandatory = $true)] [System.String] $Name, + [Parameter(Mandatory = $true)] [ValidateSet('PASS', 'FAIL', 'WARN', 'SKIP')] [System.String] $Status, + [Parameter()] [System.String] $Detail = '' + ) + + $script:results.Add([PSCustomObject]@{ Section = $Section; Name = $Name; Status = $Status; Detail = $Detail }) + + switch ($Status) { + 'PASS' { $color = 'Green'; $glyph = '[ OK ]' } + 'FAIL' { $color = 'Red'; $glyph = '[FAIL]' } + 'WARN' { $color = 'Yellow'; $glyph = '[WARN]' } + 'SKIP' { $color = 'DarkGray'; $glyph = '[SKIP]' } + } + $line = '{0} {1}' -f $glyph, $Name + if (-not [string]::IsNullOrEmpty($Detail)) { $line += " - $Detail" } + Write-Host $line -ForegroundColor $color +} + +function Write-Section { + param ([System.String] $Title) + Write-Host '' + Write-Host "== $Title ==" -ForegroundColor Cyan +} + +Write-Host '' +Write-Host '============================================================' -ForegroundColor Cyan +Write-Host ' SPSUpdate - Readiness Check' -ForegroundColor Cyan +Write-Host " Computer : $env:COMPUTERNAME" -ForegroundColor Cyan +Write-Host " Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm')" -ForegroundColor Cyan +Write-Host '============================================================' -ForegroundColor Cyan + +# 1. Module +Write-Section -Title 'Module' +$modulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules\SPSUpdate.Common\SPSUpdate.Common.psd1' +if (Test-Path -Path $modulePath) { + try { + Import-Module -Name $modulePath -Force -ErrorAction Stop + $version = (Get-Module -Name SPSUpdate.Common).Version + Add-CheckResult -Section 'Module' -Name 'SPSUpdate.Common import' -Status 'PASS' -Detail "v$version" + } + catch { + Add-CheckResult -Section 'Module' -Name 'SPSUpdate.Common import' -Status 'FAIL' -Detail $_.Exception.Message + } +} +else { + Add-CheckResult -Section 'Module' -Name 'SPSUpdate.Common import' -Status 'FAIL' -Detail "Module not found at $modulePath" +} + +# 2. Configuration +Write-Section -Title 'Configuration' +$cfg = $null +if (-not (Test-Path -Path $ConfigFile)) { + Add-CheckResult -Section 'Config' -Name 'Configuration file' -Status 'FAIL' -Detail "Not found: $ConfigFile" +} +else { + try { + $cfg = Import-PowerShellDataFile -Path $ConfigFile -ErrorAction Stop + Add-CheckResult -Section 'Config' -Name 'Configuration file' -Status 'PASS' -Detail $ConfigFile + } + catch { + Add-CheckResult -Section 'Config' -Name 'Configuration file' -Status 'FAIL' -Detail "Parse error: $($_.Exception.Message)" + } +} + +if ($null -ne $cfg) { + foreach ($key in @('ConfigurationName', 'ApplicationName', 'Domain', 'FarmName', 'CredentialKey')) { + if ($cfg.Contains($key) -and -not [string]::IsNullOrWhiteSpace([string]$cfg[$key])) { + Add-CheckResult -Section 'Config' -Name "Key '$key'" -Status 'PASS' + } + else { + Add-CheckResult -Section 'Config' -Name "Key '$key'" -Status 'FAIL' -Detail 'Missing or empty' + } + } + + if ($cfg.Contains('Binaries') -and $cfg.Binaries) { + if ([string]::IsNullOrEmpty($cfg.Binaries.SetupFullPath)) { + Add-CheckResult -Section 'Config' -Name 'Binaries.SetupFullPath' -Status 'WARN' -Detail 'Empty (required when ProductUpdate is enabled)' + } + else { + Add-CheckResult -Section 'Config' -Name 'Binaries.SetupFullPath' -Status 'PASS' -Detail $cfg.Binaries.SetupFullPath + } + } + else { + Add-CheckResult -Section 'Config' -Name 'Binaries block' -Status 'WARN' -Detail 'Missing (ProductUpdate defaults will apply)' + } +} + +# 3. Secrets (DPAPI) +Write-Section -Title 'Secrets' +if ($null -ne $cfg -and $cfg.Contains('CredentialKey') -and $cfg.CredentialKey) { + $configFolder = Split-Path -Path $ConfigFile -Parent + if ([string]::IsNullOrEmpty($configFolder)) { $configFolder = '.' } + $secretsPath = Join-Path -Path $configFolder -ChildPath 'secrets.psd1' + if (-not (Test-Path -Path $secretsPath)) { + Add-CheckResult -Section 'Secrets' -Name 'secrets.psd1' -Status 'FAIL' -Detail "Not found at $secretsPath. Run SPSUpdate.ps1 -Action Install as the service account." + } + else { + if ($null -eq (Get-Module -Name SPSUpdate.Common)) { + Add-CheckResult -Section 'Secrets' -Name 'Get-SPSSecret' -Status 'SKIP' -Detail 'Module not loaded; cannot validate the secret' + } + else { + try { + $cred = Get-SPSSecret -CredentialKey $cfg.CredentialKey -ConfigPath $configFolder -ErrorAction Stop + if ($null -ne $cred -and $cred.GetNetworkCredential().Password.Length -gt 0) { + Add-CheckResult -Section 'Secrets' -Name "Credential '$($cfg.CredentialKey)'" -Status 'PASS' -Detail "DPAPI decrypt OK (user: $($cred.UserName))" + } + else { + Add-CheckResult -Section 'Secrets' -Name "Credential '$($cfg.CredentialKey)'" -Status 'FAIL' -Detail 'Not found in secrets.psd1' + } + } + catch { + Add-CheckResult -Section 'Secrets' -Name "Credential '$($cfg.CredentialKey)'" -Status 'FAIL' -Detail "Decrypt failed (wrong account/machine?): $($_.Exception.Message)" + } + } + } +} +else { + Add-CheckResult -Section 'Secrets' -Name 'CredentialKey' -Status 'SKIP' -Detail 'No CredentialKey in config' +} + +# 4. Privileges +Write-Section -Title 'Privileges' +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator') +if ($isAdmin) { + Add-CheckResult -Section 'Privileges' -Name 'Administrator rights' -Status 'PASS' +} +else { + Add-CheckResult -Section 'Privileges' -Name 'Administrator rights' -Status 'FAIL' -Detail 'Run elevated (needed for the Event Log and SharePoint cmdlets)' +} + +# 5. Status store (UNC share for the live dashboard) +Write-Section -Title 'Status store' +if ($null -ne $cfg -and $cfg.Contains('StatusStorePath') -and -not [string]::IsNullOrWhiteSpace([string]$cfg.StatusStorePath)) { + $storePath = [string]$cfg.StatusStorePath + if (-not (Test-Path -Path $storePath)) { + Add-CheckResult -Section 'StatusStore' -Name 'Status store path' -Status 'FAIL' -Detail "Not reachable: $storePath" + } + else { + $probe = Join-Path -Path $storePath -ChildPath (".spsupdate-readiness-{0}.tmp" -f ([guid]::NewGuid().ToString('N'))) + try { + Set-Content -Path $probe -Value 'readiness' -ErrorAction Stop + Remove-Item -Path $probe -Force -ErrorAction SilentlyContinue + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable' -Status 'PASS' -Detail $storePath + } + catch { + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable' -Status 'FAIL' -Detail "Cannot write to $storePath : $($_.Exception.Message)" + } + } +} +else { + Add-CheckResult -Section 'StatusStore' -Name 'StatusStorePath' -Status 'WARN' -Detail 'Not set; the live dashboard will use the local Results\status folder and will not capture ProductUpdate on other servers' +} + +# 6. Network / CredSSP reachability +Write-Section -Title 'Network' +if ($SkipNetwork) { + Add-CheckResult -Section 'Network' -Name 'Farm reachability' -Status 'SKIP' -Detail '-SkipNetwork specified' +} +elseif ($null -ne $cfg -and $cfg.Contains('Domain') -and $cfg.Domain) { + $targets = New-Object System.Collections.Generic.List[string] + + if (-not $SkipSharePoint -and (Get-Command -Name Get-SPServer -ErrorAction SilentlyContinue)) { + try { + $farmServers = @(Get-SPServer | Where-Object { $_.Role -ne 'Invalid' } | Select-Object -ExpandProperty Name) + foreach ($s in $farmServers) { + $fqdn = if ($s -like '*.*') { $s } else { "$s.$($cfg.Domain)" } + if ($targets -notcontains $fqdn) { $targets.Add($fqdn) } + } + Add-CheckResult -Section 'Network' -Name 'Farm server enumeration' -Status 'PASS' -Detail "$($farmServers.Count) server(s) via Get-SPServer" + } + catch { + Add-CheckResult -Section 'Network' -Name 'Farm server enumeration' -Status 'SKIP' -Detail "Get-SPServer unavailable: $($_.Exception.Message)" + } + } + else { + Add-CheckResult -Section 'Network' -Name 'Farm server enumeration' -Status 'SKIP' -Detail 'SharePoint not loaded; cannot enumerate servers' + } + + foreach ($target in ($targets | Sort-Object -Unique)) { + $cim = $null + try { + $opt = New-CimSessionOption -Protocol Wsman + $cim = New-CimSession -ComputerName $target -OperationTimeoutSec $TimeoutSeconds -SessionOption $opt -ErrorAction Stop + Add-CheckResult -Section 'Network' -Name "WinRM to $target" -Status 'PASS' -Detail 'Confirm CredSSP is enabled for the full run' + } + catch { + Add-CheckResult -Section 'Network' -Name "WinRM to $target" -Status 'WARN' -Detail "Unreachable within ${TimeoutSeconds}s: $($_.Exception.Message)" + } + finally { + if ($cim) { Remove-CimSession -CimSession $cim -ErrorAction SilentlyContinue } + } + } +} +else { + Add-CheckResult -Section 'Network' -Name 'Farm reachability' -Status 'SKIP' -Detail 'No Domain to build server FQDNs' +} + +# Summary +$fail = @($script:results | Where-Object Status -eq 'FAIL').Count +$warn = @($script:results | Where-Object Status -eq 'WARN').Count +$pass = @($script:results | Where-Object Status -eq 'PASS').Count +Write-Host '' +Write-Host '============================================================' -ForegroundColor Cyan +Write-Host (' Summary : {0} passed, {1} warning(s), {2} failure(s)' -f $pass, $warn, $fail) -ForegroundColor Cyan +Write-Host '============================================================' -ForegroundColor Cyan + +if ($fail -gt 0) { exit 1 } else { exit 0 } From 2414f9859f06fad10e10c5f1e09d3cc582d66de0 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 16:34:49 +0200 Subject: [PATCH 02/15] feat: add the patching status store (Set/Get-SPSUpdateStatus) Core of the v4.2.0 live dashboard data layer: - Set-SPSUpdateStatus: atomic per-scope JSON upsert (temp file + move, with a short retry for transient UNC sharing violations). A scope is Server+Scope, so each writer (the 4 sequence tasks, each server's local ProductUpdate, the master recording every server's wizard state) owns a distinct file and they never write concurrently. Optionally upserts a per-item state (DB / setup file) with detail and exit code. - Get-SPSUpdateStatus: merges every scope file of a campaign, skipping missing, empty, half-written or *.tmp.* files so a partial campaign never throws. - Get-SPSStatusCampaignPath: resolves \--, falling back to a local Results\status folder when StatusStorePath is empty. - Export the three functions; cross-platform Pester tests for the round-trip, item accumulation/update-in-place, scope/server isolation, and resilience. 149 Pester tests passing; PSScriptAnalyzer clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Public/Get-SPSStatusCampaignPath.ps1 | 81 +++++++ .../Public/Get-SPSUpdateStatus.ps1 | 55 +++++ .../Public/Set-SPSUpdateStatus.ps1 | 210 ++++++++++++++++++ .../SPSUpdate.Common/SPSUpdate.Common.psd1 | 3 + tests/SPSUpdate.Common.Tests.ps1 | 3 + tests/SPSUpdateStatus.Tests.ps1 | 94 ++++++++ 6 files changed, 446 insertions(+) create mode 100644 src/Modules/SPSUpdate.Common/Public/Get-SPSStatusCampaignPath.ps1 create mode 100644 src/Modules/SPSUpdate.Common/Public/Get-SPSUpdateStatus.ps1 create mode 100644 src/Modules/SPSUpdate.Common/Public/Set-SPSUpdateStatus.ps1 create mode 100644 tests/SPSUpdateStatus.Tests.ps1 diff --git a/src/Modules/SPSUpdate.Common/Public/Get-SPSStatusCampaignPath.ps1 b/src/Modules/SPSUpdate.Common/Public/Get-SPSStatusCampaignPath.ps1 new file mode 100644 index 0000000..2d48817 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Get-SPSStatusCampaignPath.ps1 @@ -0,0 +1,81 @@ +function Get-SPSStatusCampaignPath { + <# + .SYNOPSIS + Resolves the campaign folder of the patching status store. + + .DESCRIPTION + Builds the folder that holds the status scope files for one patching campaign: + \--. The root is the configured + StatusStorePath (a UNC share shared by every server) when provided, otherwise it + falls back to a local 'status' folder under the Results folder (in which case + ProductUpdate runs launched on other servers are not captured centrally). + + The folder is created when -CreateIfMissing is specified. + + .PARAMETER StatusStorePath + The configured StatusStorePath (UNC share). Empty to use the local fallback. + + .PARAMETER ResultsFolder + The local Results folder used for the fallback when StatusStorePath is empty. + + .PARAMETER Application + Application code (from the config). + + .PARAMETER Environment + Environment/configuration name (from the config). + + .PARAMETER FarmName + Farm name (from the config). + + .PARAMETER CreateIfMissing + Create the campaign folder if it does not exist. + + .EXAMPLE + Get-SPSStatusCampaignPath -StatusStorePath $cfg.StatusStorePath -ResultsFolder $results -Application 'contoso' -Environment 'PROD' -FarmName 'CONTENT' -CreateIfMissing + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter()] + [AllowEmptyString()] + [System.String] + $StatusStorePath, + + [Parameter(Mandatory = $true)] + [System.String] + $ResultsFolder, + + [Parameter(Mandatory = $true)] + [System.String] + $Application, + + [Parameter(Mandatory = $true)] + [System.String] + $Environment, + + [Parameter(Mandatory = $true)] + [System.String] + $FarmName, + + [Parameter()] + [switch] + $CreateIfMissing + ) + + if ([string]::IsNullOrWhiteSpace($StatusStorePath)) { + $root = Join-Path -Path $ResultsFolder -ChildPath 'status' + } + else { + $root = $StatusStorePath + } + + $campaignName = ('{0}-{1}-{2}' -f $Application, $Environment, $FarmName) -replace '[^A-Za-z0-9_.-]', '_' + $campaignPath = Join-Path -Path $root -ChildPath $campaignName + + if ($CreateIfMissing -and -not (Test-Path -Path $campaignPath)) { + $null = New-Item -Path $campaignPath -ItemType Directory -Force + } + + return $campaignPath +} diff --git a/src/Modules/SPSUpdate.Common/Public/Get-SPSUpdateStatus.ps1 b/src/Modules/SPSUpdate.Common/Public/Get-SPSUpdateStatus.ps1 new file mode 100644 index 0000000..54f42b0 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Get-SPSUpdateStatus.ps1 @@ -0,0 +1,55 @@ +function Get-SPSUpdateStatus { + <# + .SYNOPSIS + Reads and merges every patching progress record of a campaign. + + .DESCRIPTION + Get-SPSUpdateStatus reads all scope files (`*__*.json`) written by + Set-SPSUpdateStatus in the campaign folder and returns them as an array of + scope objects (Server, Scope, Phase, State, Detail, Percent, StartedAt, + UpdatedAt, Items). It is the data source for Export-SPSUpdateProgressReport. + + Files that are missing, empty or momentarily unreadable (being written) are + skipped silently so a partially captured campaign never throws. Temporary + files (`*.tmp.*`) are ignored. + + .PARAMETER CampaignPath + Folder of the patching campaign to read (for example + \--). + + .EXAMPLE + Get-SPSUpdateStatus -CampaignPath $c | Sort-Object Server, Scope + #> + [CmdletBinding()] + [OutputType([System.Object[]])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $CampaignPath + ) + + if (-not (Test-Path -Path $CampaignPath)) { + return @() + } + + $files = Get-ChildItem -Path $CampaignPath -Filter '*.json' -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike '*.tmp.*' } + + $records = New-Object System.Collections.Generic.List[object] + foreach ($file in $files) { + try { + $raw = Get-Content -Path $file.FullName -Raw -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace($raw)) { continue } + $record = $raw | ConvertFrom-Json -ErrorAction Stop + if ($null -ne $record) { + $records.Add($record) + } + } + catch { + Write-Verbose -Message "Get-SPSUpdateStatus: skipping unreadable '$($file.FullName)': $($_.Exception.Message)" + } + } + + return $records.ToArray() +} diff --git a/src/Modules/SPSUpdate.Common/Public/Set-SPSUpdateStatus.ps1 b/src/Modules/SPSUpdate.Common/Public/Set-SPSUpdateStatus.ps1 new file mode 100644 index 0000000..13ed0cf --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Set-SPSUpdateStatus.ps1 @@ -0,0 +1,210 @@ +function Set-SPSUpdateStatus { + <# + .SYNOPSIS + Writes (upserts) a patching progress record into the shared status store. + + .DESCRIPTION + Set-SPSUpdateStatus persists one "scope" of patching progress as a JSON file + in the campaign folder of the status store (a UNC share shared by every farm + server, or a local folder as a fallback). It is the building block of the + near-real-time SPSUpdate dashboard. + + A scope is identified by Server + Scope (e.g. APP1 + 'Sequence1'). Each writer + owns a distinct scope file, so the four parallel sequence tasks, the local + ProductUpdate on each server and the master (which records the wizard state of + every server) never write the same file concurrently. Writes are atomic + (written to a temporary file then moved into place) so a reader never sees a + half-written document, with a short retry to absorb transient UNC sharing + violations. + + Optionally upserts a single item inside the scope (e.g. a database name or a + setup file) with its own state / detail / exit code, so the dashboard can show + per-database or per-binary progress. + + .PARAMETER CampaignPath + Folder of the current patching campaign (for example + \--). Created if missing. + + .PARAMETER Scope + Scope key that identifies the owning writer and names the file + (__.json). Examples: 'ProductUpdate', 'Sequence1', 'Wizard'. + + .PARAMETER Phase + Logical phase the scope belongs to, used to group the dashboard. + + .PARAMETER Server + Server the scope relates to. Defaults to the local computer name. + + .PARAMETER State + Scope-level state. + + .PARAMETER Detail + Free-form scope-level detail. + + .PARAMETER Percent + Optional scope-level completion percentage (0-100). + + .PARAMETER Item + Optional item name to upsert inside the scope (e.g. a database or a setup file). + + .PARAMETER ItemState + State of the upserted item. + + .PARAMETER ItemDetail + Detail of the upserted item. + + .PARAMETER ExitCode + Optional exit code recorded on the item. + + .EXAMPLE + Set-SPSUpdateStatus -CampaignPath $c -Scope 'Sequence1' -Phase 'Upgrade' -State 'Running' -Item 'DB_A' -ItemState 'Done' -ItemDetail 'upgraded' -ExitCode 0 + #> + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $CampaignPath, + + [Parameter(Mandatory = $true)] + [System.String] + $Scope, + + [Parameter()] + [ValidateSet('ProductUpdate', 'Mount', 'Upgrade', 'Sequence', 'Wizard', 'SideBySide')] + [System.String] + $Phase = 'Sequence', + + [Parameter()] + [System.String] + $Server = $env:COMPUTERNAME, + + [Parameter()] + [ValidateSet('Pending', 'Running', 'Done', 'Failed', 'Warning', 'Skipped')] + [System.String] + $State, + + [Parameter()] + [System.String] + $Detail, + + [Parameter()] + [System.Nullable[int]] + $Percent, + + [Parameter()] + [System.String] + $Item, + + [Parameter()] + [ValidateSet('Pending', 'Running', 'Done', 'Failed', 'Warning', 'Skipped')] + [System.String] + $ItemState, + + [Parameter()] + [System.String] + $ItemDetail, + + [Parameter()] + [System.Nullable[int]] + $ExitCode + ) + + if (-not (Test-Path -Path $CampaignPath)) { + $null = New-Item -Path $CampaignPath -ItemType Directory -Force + } + + $safeServer = ($Server -replace '[^A-Za-z0-9_.-]', '_') + $safeScope = ($Scope -replace '[^A-Za-z0-9_.-]', '_') + $fileName = '{0}__{1}.json' -f $safeServer, $safeScope + $filePath = Join-Path -Path $CampaignPath -ChildPath $fileName + $now = (Get-Date).ToString('o') + + # Load the existing scope record (if any) so items accumulate across calls. + $record = $null + if (Test-Path -Path $filePath) { + try { + $raw = Get-Content -Path $filePath -Raw -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($raw)) { + $record = $raw | ConvertFrom-Json -ErrorAction Stop + } + } + catch { + Write-Verbose -Message "Set-SPSUpdateStatus: could not read existing '$filePath', starting fresh: $($_.Exception.Message)" + } + } + + if ($null -eq $record) { + $record = [PSCustomObject]@{ + Server = $Server + Scope = $Scope + Phase = $Phase + State = 'Pending' + Detail = '' + Percent = $null + StartedAt = $now + UpdatedAt = $now + Items = @() + } + } + + $record.Server = $Server + $record.Scope = $Scope + $record.Phase = $Phase + $record.UpdatedAt = $now + if ($PSBoundParameters.ContainsKey('State')) { $record.State = $State } + if ($PSBoundParameters.ContainsKey('Detail')) { $record.Detail = $Detail } + if ($PSBoundParameters.ContainsKey('Percent')) { $record.Percent = $Percent } + + # Upsert the optional item. + if ($PSBoundParameters.ContainsKey('Item') -and -not [string]::IsNullOrEmpty($Item)) { + $items = @($record.Items) + $existing = $items | Where-Object { $_.Name -eq $Item } | Select-Object -First 1 + if ($null -eq $existing) { + $existing = [PSCustomObject]@{ + Name = $Item + State = 'Pending' + Detail = '' + ExitCode = $null + UpdatedAt = $now + } + $items += $existing + } + $existing.UpdatedAt = $now + if ($PSBoundParameters.ContainsKey('ItemState')) { $existing.State = $ItemState } + if ($PSBoundParameters.ContainsKey('ItemDetail')) { $existing.Detail = $ItemDetail } + if ($PSBoundParameters.ContainsKey('ExitCode')) { $existing.ExitCode = $ExitCode } + $record.Items = $items + } + + if (-not $PSCmdlet.ShouldProcess($filePath, 'Write SPSUpdate status')) { + return $filePath + } + + $json = $record | ConvertTo-Json -Depth 6 + $tmpPath = '{0}.tmp.{1}' -f $filePath, ([guid]::NewGuid().ToString('N')) + $encoding = New-Object System.Text.UTF8Encoding($false) + + $attempts = 0 + $written = $false + while (-not $written -and $attempts -lt 5) { + $attempts++ + try { + [System.IO.File]::WriteAllText($tmpPath, $json, $encoding) + Move-Item -Path $tmpPath -Destination $filePath -Force -ErrorAction Stop + $written = $true + } + catch { + if (Test-Path -Path $tmpPath) { Remove-Item -Path $tmpPath -Force -ErrorAction SilentlyContinue } + if ($attempts -ge 5) { + Write-Warning -Message "Set-SPSUpdateStatus: failed to write '$filePath' after $attempts attempts: $($_.Exception.Message)" + } + else { + Start-Sleep -Milliseconds (150 * $attempts) + } + } + } + + return $filePath +} diff --git a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 index 53f2a15..a341ea8 100644 --- a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 +++ b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 @@ -17,11 +17,14 @@ 'Get-SPSInstalledProductVersion' 'Get-SPSSecret' 'Get-SPSServersPatchStatus' + 'Get-SPSStatusCampaignPath' + 'Get-SPSUpdateStatus' 'Initialize-SPSContentDbJsonFile' 'Mount-SPSContentDatabase' 'Remove-SPSScheduledTask' 'Set-SPSSecret' 'Set-SPSSideBySideToken' + 'Set-SPSUpdateStatus' 'Start-SPSConfigExe' 'Start-SPSConfigExeRemote' 'Start-SPSProductUpdate' diff --git a/tests/SPSUpdate.Common.Tests.ps1 b/tests/SPSUpdate.Common.Tests.ps1 index 7b97152..cc7ef1d 100644 --- a/tests/SPSUpdate.Common.Tests.ps1 +++ b/tests/SPSUpdate.Common.Tests.ps1 @@ -49,11 +49,14 @@ Describe 'SPSUpdate.Common module' { 'Get-SPSInstalledProductVersion' 'Get-SPSSecret' 'Get-SPSServersPatchStatus' + 'Get-SPSStatusCampaignPath' + 'Get-SPSUpdateStatus' 'Initialize-SPSContentDbJsonFile' 'Mount-SPSContentDatabase' 'Remove-SPSScheduledTask' 'Set-SPSSecret' 'Set-SPSSideBySideToken' + 'Set-SPSUpdateStatus' 'Start-SPSConfigExe' 'Start-SPSConfigExeRemote' 'Start-SPSProductUpdate' diff --git a/tests/SPSUpdateStatus.Tests.ps1 b/tests/SPSUpdateStatus.Tests.ps1 new file mode 100644 index 0000000..ae2ce55 --- /dev/null +++ b/tests/SPSUpdateStatus.Tests.ps1 @@ -0,0 +1,94 @@ +# Tests for the patching status store (Set-/Get-SPSUpdateStatus, Get-SPSStatusCampaignPath). +# Cross-platform: plain JSON files on the local filesystem. + +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:root = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("spsupd-status-" + [guid]::NewGuid()) + New-Item -Path $script:root -ItemType Directory -Force | Out-Null +} + +AfterAll { + if ($script:root -and (Test-Path $script:root)) { + Remove-Item -Path $script:root -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-Module -Name SPSUpdate.Common -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-SPSStatusCampaignPath' { + It 'falls back to Results\status when StatusStorePath is empty' { + $p = Get-SPSStatusCampaignPath -StatusStorePath '' -ResultsFolder $script:root -Application 'contoso' -Environment 'PROD' -FarmName 'CONTENT' + $p | Should -Be (Join-Path -Path (Join-Path -Path $script:root -ChildPath 'status') -ChildPath 'contoso-PROD-CONTENT') + } + + It 'uses the configured store path when provided' { + $store = Join-Path -Path $script:root -ChildPath 'share' + $p = Get-SPSStatusCampaignPath -StatusStorePath $store -ResultsFolder $script:root -Application 'contoso' -Environment 'PROD' -FarmName 'CONTENT' + $p | Should -Be (Join-Path -Path $store -ChildPath 'contoso-PROD-CONTENT') + } + + It 'creates the folder with -CreateIfMissing' { + $store = Join-Path -Path $script:root -ChildPath 'make' + $p = Get-SPSStatusCampaignPath -StatusStorePath $store -ResultsFolder $script:root -Application 'a' -Environment 'e' -FarmName 'f' -CreateIfMissing + Test-Path $p | Should -BeTrue + } +} + +Describe 'Set-SPSUpdateStatus / Get-SPSUpdateStatus round-trip' { + BeforeAll { + $script:camp = Join-Path -Path $script:root -ChildPath 'campaign1' + } + + It 'writes a scope file named __.json' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Running' -Confirm:$false | Out-Null + Test-Path (Join-Path -Path $script:camp -ChildPath 'APP1__Sequence1.json') | Should -BeTrue + } + + It 'accumulates items across calls inside the same scope' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -Item 'DB_A' -ItemState 'Done' -ItemDetail 'upgraded' -ExitCode 0 -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Done' -Item 'DB_B' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + + $all = Get-SPSUpdateStatus -CampaignPath $script:camp + $seq1 = $all | Where-Object { $_.Scope -eq 'Sequence1' } + @($seq1.Items).Count | Should -Be 2 + ($seq1.Items | Where-Object Name -eq 'DB_A').State | Should -Be 'Done' + $seq1.State | Should -Be 'Done' + } + + It 'updates an existing item in place rather than duplicating it' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -Item 'DB_A' -ItemState 'Warning' -ItemDetail 'retried' -Confirm:$false | Out-Null + $seq1 = Get-SPSUpdateStatus -CampaignPath $script:camp | Where-Object { $_.Scope -eq 'Sequence1' } + @($seq1.Items | Where-Object Name -eq 'DB_A').Count | Should -Be 1 + ($seq1.Items | Where-Object Name -eq 'DB_A').State | Should -Be 'Warning' + } + + It 'keeps separate scopes and servers in separate files' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'WFE1' -State 'Running' -Item 'uber.exe' -ItemState 'Running' -Confirm:$false | Out-Null + $all = Get-SPSUpdateStatus -CampaignPath $script:camp + ($all | Where-Object { $_.Server -eq 'WFE1' -and $_.Scope -eq 'ProductUpdate' }) | Should -Not -BeNullOrEmpty + @($all).Count | Should -Be 2 + } + + It 'records the optional percent' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Wizard' -Phase 'Wizard' -Server 'APP1' -State 'Running' -Percent 50 -Confirm:$false | Out-Null + $w = Get-SPSUpdateStatus -CampaignPath $script:camp | Where-Object { $_.Scope -eq 'Wizard' } + $w.Percent | Should -Be 50 + } +} + +Describe 'Get-SPSUpdateStatus resilience' { + It 'returns an empty array for a missing campaign folder' { + $missing = Join-Path -Path $script:root -ChildPath 'does-not-exist' + @(Get-SPSUpdateStatus -CampaignPath $missing).Count | Should -Be 0 + } + + It 'ignores temporary (*.tmp.*) files' { + $camp = Join-Path -Path $script:root -ChildPath 'campaign2' + New-Item -Path $camp -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $camp 'APP1__Wizard.json.tmp.abc') -Value '{ broken' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Wizard' -Phase 'Wizard' -Server 'APP1' -State 'Done' -Confirm:$false | Out-Null + @(Get-SPSUpdateStatus -CampaignPath $camp).Count | Should -Be 1 + } +} From bbf420ca5b29c5dd6441d9573984df2e69268883 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 16:38:41 +0200 Subject: [PATCH 03/15] feat: add the live patching dashboard (Export-SPSUpdateProgressReport) Render a self-contained HTML dashboard from the campaign status store: - Overall roll-up cards (state, servers, done/running/failed) and one section per phase (ProductUpdate, Mount/Upgrade sequences, Wizard, SideBySide) with colored state badges, per-item tables (state/detail/exit code) and per-scope percentage. - Server-rendered (no fetch), so it works over file:// from the share; carries a meta-refresh so an open browser updates itself while patching runs, and -Completed turns the refresh off with a final state. Atomic write so a reader never sees a half-written page. - Extend the shared HTML head helper with an optional meta-refresh and state badge styles. Export the function; cross-platform tests cover the running view, the Failed and Completed states, the empty campaign, and HTML encoding. 161 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Private/Get-SPSReportHtmlHead.ps1 | 22 +- .../Public/Export-SPSUpdateProgressReport.ps1 | 249 ++++++++++++++++++ .../SPSUpdate.Common/SPSUpdate.Common.psd1 | 1 + .../Export-SPSUpdateProgressReport.Tests.ps1 | 100 +++++++ tests/SPSUpdate.Common.Tests.ps1 | 1 + 5 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 create mode 100644 tests/Export-SPSUpdateProgressReport.Tests.ps1 diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 index d5118b2..102738b 100644 --- a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 @@ -9,7 +9,11 @@ ( [Parameter(Mandatory = $true)] [System.String] - $Title + $Title, + + [Parameter()] + [System.Int32] + $RefreshSeconds = 0 ) $css = @' @@ -43,8 +47,22 @@ tbody tr:nth-child(even){background:var(--zebra)} .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} +.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;color:#fff} +.badge.Pending{background:#9aa4ad} +.badge.Running{background:#1f6fb2} +.badge.Done{background:#2e9b57} +.badge.Failed{background:#c0392b} +.badge.Warning{background:#c19c00;color:#222} +.badge.Skipped{background:#cfd6dc;color:#333} +.phase{margin-top:18px} +.scope{border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin:8px 0;background:#fff} +.scope-head{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--brand-dark)} +.scope-detail{color:var(--muted);font-size:11px;margin-top:2px} +.items{margin-top:6px} +.live{color:#2e9b57;font-size:11px} .footer{color:var(--muted);font-size:11px;margin-top:24px;border-top:1px solid var(--line);padding-top:8px} '@ - return "$Title" + $refreshTag = if ($RefreshSeconds -gt 0) { "" } else { '' } + return "$refreshTag$Title" } diff --git a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 new file mode 100644 index 0000000..edcc1d1 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 @@ -0,0 +1,249 @@ +function Export-SPSUpdateProgressReport { + <# + .SYNOPSIS + Renders the live patching progress dashboard from the status store. + + .DESCRIPTION + Export-SPSUpdateProgressReport reads every status scope of a campaign (via + Get-SPSUpdateStatus) and renders a single, self-contained HTML dashboard that + shows the overall patching progress and the detail of each phase + (ProductUpdate per server, the four upgrade/mount sequences, the post-setup + Configuration Wizard per server, and side-by-side). It is regenerated + periodically by the master during the run, and the page carries a meta-refresh + so an open browser updates on its own. + + The dashboard is fully server-rendered (no fetch), so it works from a file + share opened over file:// as well as over HTTP. All values are HTML-encoded. + + Returns the path of the dashboard that was written. + + .PARAMETER CampaignPath + Folder of the patching campaign to read (the campaign status files live here, + and the dashboard is written here by default). + + .PARAMETER OutputFile + Destination path of the dashboard. Defaults to '_dashboard.html' in CampaignPath. + + .PARAMETER Title + Heading shown at the top. Defaults to a generic title. + + .PARAMETER EnvName + Environment label shown in the metadata line. + + .PARAMETER AppCode + Application code shown in the metadata line. + + .PARAMETER FarmName + Farm label shown in the metadata line. + + .PARAMETER RefreshSeconds + Meta-refresh interval (seconds). 0 disables auto-refresh. Default 15. + + .PARAMETER Completed + Mark the campaign as completed: disables the auto-refresh and shows a final state. + + .PARAMETER Version + SPSUpdate version stamped in the footer. Defaults to the module version. + + .EXAMPLE + Export-SPSUpdateProgressReport -CampaignPath $c -EnvName 'PROD' -AppCode 'contoso' -FarmName 'CONTENT' + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $CampaignPath, + + [Parameter()] + [System.String] + $OutputFile, + + [Parameter()] + [System.String] + $Title, + + [Parameter()] + [System.String] + $EnvName, + + [Parameter()] + [System.String] + $AppCode, + + [Parameter()] + [System.String] + $FarmName, + + [Parameter()] + [System.Int32] + $RefreshSeconds = 15, + + [Parameter()] + [switch] + $Completed, + + [Parameter()] + [System.String] + $Version + ) + + if ([string]::IsNullOrEmpty($OutputFile)) { + $OutputFile = Join-Path -Path $CampaignPath -ChildPath '_dashboard.html' + } + if ([string]::IsNullOrEmpty($Title)) { $Title = 'SPSUpdate - Patching Progress' } + if ([string]::IsNullOrEmpty($Version)) { + $moduleVersion = (Get-Module -Name SPSUpdate.Common -ErrorAction SilentlyContinue).Version + $Version = if ($null -ne $moduleVersion) { $moduleVersion.ToString() } else { 'unknown' } + } + + $scopes = @(Get-SPSUpdateStatus -CampaignPath $CampaignPath) + + # Helper: a colored state badge. + $badge = { + param($state) + $s = if ([string]::IsNullOrEmpty($state)) { 'Pending' } else { "$state" } + $encoded = ConvertTo-SPSHtmlEncoded -Value $s + "$encoded" + } + + # ---- Overall roll-up ---------------------------------------------------------- + $allItemStates = New-Object System.Collections.Generic.List[string] + foreach ($sc in $scopes) { + if ($sc.State) { $allItemStates.Add("$($sc.State)") } + foreach ($it in @($sc.Items)) { if ($it.State) { $allItemStates.Add("$($it.State)") } } + } + $countFailed = @($allItemStates | Where-Object { $_ -eq 'Failed' }).Count + $countRunning = @($allItemStates | Where-Object { $_ -eq 'Running' }).Count + $countDone = @($allItemStates | Where-Object { $_ -eq 'Done' }).Count + $countPending = @($allItemStates | Where-Object { $_ -eq 'Pending' }).Count + $countWarning = @($allItemStates | Where-Object { $_ -eq 'Warning' }).Count + + $overall = if ($scopes.Count -eq 0) { 'Pending' } + elseif ($countFailed -gt 0) { 'Failed' } + elseif ($countRunning -gt 0 -or $countPending -gt 0) { 'Running' } + elseif ($countWarning -gt 0) { 'Warning' } + else { 'Done' } + if ($Completed -and $overall -eq 'Running') { $overall = 'Done' } + + $serverCount = @($scopes | Select-Object -ExpandProperty Server -Unique | Where-Object { $_ }).Count + + # ---- Summary cards ------------------------------------------------------------ + $cards = @( + "
$(& $badge $overall)
Overall state
" + (Get-SPSReportCardHtml -Value $serverCount -Label 'Servers') + (Get-SPSReportCardHtml -Value $countDone -Label 'Done') + (Get-SPSReportCardHtml -Value $countRunning -Label 'Running') + (Get-SPSReportCardHtml -Value $countFailed -Label 'Failed') + ) -join '' + + # ---- Phase sections ----------------------------------------------------------- + $phaseOrder = @('ProductUpdate', 'Mount', 'Upgrade', 'Sequence', 'Wizard', 'SideBySide') + $phaseLabels = @{ + ProductUpdate = 'Product update (binaries)' + Mount = 'Content database mount' + Upgrade = 'Content database upgrade' + Sequence = 'Content database sequences' + Wizard = 'Configuration Wizard (PSConfig)' + SideBySide = 'Side-by-side token' + } + + $sections = '' + $presentPhases = @($scopes | Select-Object -ExpandProperty Phase -Unique) + $orderedPhases = @($phaseOrder | Where-Object { $presentPhases -contains $_ }) + $orderedPhases += @($presentPhases | Where-Object { $phaseOrder -notcontains $_ }) + + foreach ($phase in $orderedPhases) { + $phaseScopes = @($scopes | Where-Object { $_.Phase -eq $phase } | Sort-Object Server, Scope) + if ($phaseScopes.Count -eq 0) { continue } + $label = if ($phaseLabels.ContainsKey($phase)) { $phaseLabels[$phase] } else { $phase } + $sections += "

$(ConvertTo-SPSHtmlEncoded -Value $label)

" + + foreach ($sc in $phaseScopes) { + $encServer = ConvertTo-SPSHtmlEncoded -Value "$($sc.Server)" + $encScope = ConvertTo-SPSHtmlEncoded -Value "$($sc.Scope)" + $encDetail = ConvertTo-SPSHtmlEncoded -Value "$($sc.Detail)" + $pctText = if ($null -ne $sc.Percent -and "$($sc.Percent)" -ne '') { " · $([int]$sc.Percent)%" } else { '' } + $updated = '' + if ($sc.UpdatedAt) { + try { $updated = " · updated $([datetime]::Parse($sc.UpdatedAt).ToString('HH:mm:ss'))" } catch { $updated = '' } + } + + $sections += "
$(& $badge $sc.State) $encServer / $encScope$pctText
" + if (-not [string]::IsNullOrEmpty($encDetail)) { + $sections += "
$encDetail$updated
" + } + elseif (-not [string]::IsNullOrEmpty($updated)) { + $sections += "
$($updated.TrimStart(' ·'))
" + } + + $items = @($sc.Items) + if ($items.Count -gt 0) { + $rows = '' + foreach ($it in $items) { + $encName = ConvertTo-SPSHtmlEncoded -Value "$($it.Name)" + $encItemDetail = ConvertTo-SPSHtmlEncoded -Value "$($it.Detail)" + $exit = if ($null -ne $it.ExitCode -and "$($it.ExitCode)" -ne '') { ConvertTo-SPSHtmlEncoded -Value "$($it.ExitCode)" } else { '' } + $rows += "$encName$(& $badge $it.State)$encItemDetail$exit" + } + $sections += "
$rows
ItemStateDetailExit
" + } + $sections += '
' + } + $sections += '
' + } + + if ($scopes.Count -eq 0) { + $sections = '
No status recorded yet for this campaign. Waiting for the first update...
' + } + + # ---- Metadata ----------------------------------------------------------------- + $encTitle = ConvertTo-SPSHtmlEncoded -Value $Title + $encEnv = ConvertTo-SPSHtmlEncoded -Value $EnvName + $encApp = ConvertTo-SPSHtmlEncoded -Value $AppCode + $encFarm = ConvertTo-SPSHtmlEncoded -Value $FarmName + $encVersion = ConvertTo-SPSHtmlEncoded -Value $Version + $generated = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + + $metaParts = @() + if (-not [string]::IsNullOrEmpty($encApp)) { $metaParts += "AppCode: $encApp" } + if (-not [string]::IsNullOrEmpty($encEnv)) { $metaParts += "Environment: $encEnv" } + if (-not [string]::IsNullOrEmpty($encFarm)) { $metaParts += "Farm: $encFarm" } + $metaParts += "Generated: $generated" + $metaParts += "SPSUpdate $encVersion" + $metaLine = $metaParts -join ' · ' + + $effectiveRefresh = if ($Completed) { 0 } else { $RefreshSeconds } + $liveNote = if ($effectiveRefresh -gt 0) { + "● live · auto-refresh every ${effectiveRefresh}s" + } + else { 'Campaign completed (auto-refresh off).' } + + # ---- Assemble ----------------------------------------------------------------- + $html = (Get-SPSReportHtmlHead -Title $encTitle -RefreshSeconds $effectiveRefresh) + + "

$encTitle

" + + "
$metaLine · $liveNote
" + + "

Overall

$cards
" + + $sections + + "
Generated by SPSUpdate $encVersion. This dashboard is assembled from the shared status store and refreshes itself while patching is in progress.
" + + '' + + $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 + } + # Atomic write so an open browser never reads a half-written dashboard. + $tmpPath = '{0}.tmp.{1}' -f $OutputFile, ([guid]::NewGuid().ToString('N')) + $encoding = New-Object System.Text.UTF8Encoding($true) + try { + [System.IO.File]::WriteAllText($tmpPath, $html, $encoding) + Move-Item -Path $tmpPath -Destination $OutputFile -Force -ErrorAction Stop + } + catch { + if (Test-Path -Path $tmpPath) { Remove-Item -Path $tmpPath -Force -ErrorAction SilentlyContinue } + Set-Content -Path $OutputFile -Value $html -Force -Encoding UTF8 + } + + return $OutputFile +} diff --git a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 index a341ea8..defae45 100644 --- a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 +++ b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 @@ -14,6 +14,7 @@ 'Add-SPSUpdateEvent' 'Copy-SPSSideBySideFilesRemote' 'Export-SPSUpdateDbReport' + 'Export-SPSUpdateProgressReport' 'Get-SPSInstalledProductVersion' 'Get-SPSSecret' 'Get-SPSServersPatchStatus' diff --git a/tests/Export-SPSUpdateProgressReport.Tests.ps1 b/tests/Export-SPSUpdateProgressReport.Tests.ps1 new file mode 100644 index 0000000..afae044 --- /dev/null +++ b/tests/Export-SPSUpdateProgressReport.Tests.ps1 @@ -0,0 +1,100 @@ +# Tests for the live patching dashboard (Export-SPSUpdateProgressReport). +# Cross-platform: server-rendered HTML from the local status store. + +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:root = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("spsupd-dash-" + [guid]::NewGuid()) + New-Item -Path $script:root -ItemType Directory -Force | Out-Null + + function New-Campaign { + param([string]$Name) + $camp = Join-Path -Path $script:root -ChildPath $Name + New-Item -Path $camp -ItemType Directory -Force | Out-Null + return $camp + } +} + +AfterAll { + if ($script:root -and (Test-Path $script:root)) { + Remove-Item -Path $script:root -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-Module -Name SPSUpdate.Common -Force -ErrorAction SilentlyContinue +} + +Describe 'Export-SPSUpdateProgressReport (running campaign)' { + BeforeAll { + $script:camp = New-Campaign -Name 'run' + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'APP1' -State 'Done' -Item 'uber.exe' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'WFE1' -State 'Running' -Item 'uber.exe' -ItemState 'Running' -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Running' -Percent 50 -Item 'DB_A' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + + $script:out = Export-SPSUpdateProgressReport -CampaignPath $script:camp -EnvName 'PROD' -AppCode 'zebes' -FarmName 'CONTENT' -RefreshSeconds 15 + $script:html = Get-Content -Path $script:out -Raw + } + + It 'writes _dashboard.html in the campaign folder by default' { + $script:out | Should -Be (Join-Path -Path $script:camp -ChildPath '_dashboard.html') + Test-Path $script:out | Should -BeTrue + } + + It 'emits a meta-refresh while running' { + $script:html | Should -Match 'http-equiv="refresh" content="15"' + $script:html | Should -Match 'auto-refresh every 15s' + } + + It 'shows an overall Running state' { + $script:html | Should -Match 'class="badge Running"' + $script:html | Should -Match 'Overall state' + } + + It 'groups the phases and lists the servers/items' { + $script:html | Should -Match 'Product update' + $script:html | Should -Match 'Content database upgrade' + $script:html | Should -Match 'APP1' + $script:html | Should -Match 'WFE1' + $script:html | Should -Match 'DB_A' + $script:html | Should -Match '50%' + } + + It 'is a self-contained document with no external resources' { + $script:html | Should -Match '' + $script:html | Should -Not -Match 'src="http' + $script:html | Should -Not -Match 'href="http' + } +} + +Describe 'Export-SPSUpdateProgressReport (states)' { + It 'reports Failed overall when any item failed' { + $camp = New-Campaign -Name 'fail' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Failed' -Item 'DB_X' -ItemState 'Failed' -ItemDetail 'boom' -Confirm:$false | Out-Null + $out = Export-SPSUpdateProgressReport -CampaignPath $camp + $h = Get-Content -Path $out -Raw + $h | Should -Match 'class="badge Failed"' + } + + It 'disables auto-refresh and reports Done when -Completed' { + $camp = New-Campaign -Name 'done' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Wizard' -Phase 'Wizard' -Server 'APP1' -State 'Done' -Confirm:$false | Out-Null + $out = Export-SPSUpdateProgressReport -CampaignPath $camp -Completed + $h = Get-Content -Path $out -Raw + $h | Should -Not -Match 'http-equiv="refresh"' + $h | Should -Match 'Campaign completed' + } + + It 'renders a waiting message for an empty campaign' { + $camp = New-Campaign -Name 'empty' + $out = Export-SPSUpdateProgressReport -CampaignPath $camp + (Get-Content -Path $out -Raw) | Should -Match 'No status recorded yet' + } + + It 'HTML-encodes values coming from the status store' { + $camp = New-Campaign -Name 'enc' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Running' -Item 'DB & y' -ItemState 'Running' -Confirm:$false | Out-Null + $h = Get-Content -Path (Export-SPSUpdateProgressReport -CampaignPath $camp) -Raw + $h | Should -Match 'DB <x> & y' + $h | Should -Not -Match 'DB & y' + } +} diff --git a/tests/SPSUpdate.Common.Tests.ps1 b/tests/SPSUpdate.Common.Tests.ps1 index cc7ef1d..ce1b772 100644 --- a/tests/SPSUpdate.Common.Tests.ps1 +++ b/tests/SPSUpdate.Common.Tests.ps1 @@ -46,6 +46,7 @@ Describe 'SPSUpdate.Common module' { 'Add-SPSUpdateEvent' 'Copy-SPSSideBySideFilesRemote' 'Export-SPSUpdateDbReport' + 'Export-SPSUpdateProgressReport' 'Get-SPSInstalledProductVersion' 'Get-SPSSecret' 'Get-SPSServersPatchStatus' From 9691e09176d86117f6143035515eb4801b8ae47f Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 16:42:51 +0200 Subject: [PATCH 04/15] feat: wire the live patching dashboard into SPSUpdate.ps1 Instrument the run to feed the shared status store and regenerate the dashboard: - Resolve the campaign path once (Get-SPSStatusCampaignPath) and add two best-effort local helpers, Write-SPSStatus and Write-SPSDashboard, that never block the run on a status/report failure. - New -Action ResetStatus clears the campaign folder so a fresh patching round starts clean. - ProductUpdate: per-server scope with a per-setup-file item (Running -> Done / Failed); writes to the shared store so each server's local run is captured. - Sequence tasks: per-sequence scope with per-database items and a running percentage (mount/upgrade), Done/Failed at the end. - Master full run: regenerates the dashboard on every wait-loop iteration while the four sequences progress; records the Configuration Wizard state per server (local + remote, Running/Done/Failed/Skipped) and the side-by-side step; and renders a final -Completed dashboard (auto-refresh off) at the end. - Add 'ResetStatus' to the Action set. Parse OK, PSScriptAnalyzer clean, 161 Pester tests passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SPSUpdate.ps1 | 156 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 3 deletions(-) diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index 0add91d..7ad077d 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -63,7 +63,7 @@ param $ConfigFile, # Path to the configuration file [Parameter(Position = 1)] - [validateSet('Install', 'Uninstall', 'Default', 'ProductUpdate', 'InitContentDB', IgnoreCase = $true)] + [validateSet('Install', 'Uninstall', 'Default', 'ProductUpdate', 'InitContentDB', 'ResetStatus', IgnoreCase = $true)] [System.String] $Action = 'Default', @@ -208,6 +208,74 @@ $fullScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'SPSUpdate.ps1' $spsUpdateDBsPath = Join-Path -Path $pathConfigFolder -ChildPath $spsUpdateDBsFile $spsUpdateDbReportPath = Join-Path -Path $pathResultsFolder -ChildPath $spsUpdateDbReportFile +# Resolve the patching status store campaign folder (UNC share when configured, local +# Results\status fallback otherwise). Shared by every server/task to feed the live +# dashboard. Identity is -- so same-campaign actions land together. +$thisServer = $env:COMPUTERNAME +$statusCampaignPath = $null +try { + $statusCampaignPath = Get-SPSStatusCampaignPath -StatusStorePath $envCfg.StatusStorePath ` + -ResultsFolder $pathResultsFolder ` + -Application $Application ` + -Environment $Environment ` + -FarmName $spFarmName +} +catch { + Write-Warning -Message "Could not resolve the status store campaign path: $($_.Exception.Message)" +} +$statusDashboardPath = if ($null -ne $statusCampaignPath) { Join-Path -Path $statusCampaignPath -ChildPath '_dashboard.html' } else { $null } + +# Local helper: best-effort status write. Never blocks the run on a status failure. +function Write-SPSStatus { + param( + [Parameter(Mandatory = $true)][System.String] $Scope, + [Parameter(Mandatory = $true)][ValidateSet('ProductUpdate', 'Mount', 'Upgrade', 'Sequence', 'Wizard', 'SideBySide')][System.String] $Phase, + [System.String] $Server = $thisServer, + [System.String] $State, + [System.String] $Detail, + [System.Nullable[int]] $Percent, + [System.String] $Item, + [System.String] $ItemState, + [System.String] $ItemDetail, + [System.Nullable[int]] $ExitCode + ) + if ([string]::IsNullOrEmpty($statusCampaignPath)) { return } + try { + $params = @{ CampaignPath = $statusCampaignPath; Scope = $Scope; Phase = $Phase; Server = $Server; Confirm = $false } + if ($PSBoundParameters.ContainsKey('State')) { $params.State = $State } + if ($PSBoundParameters.ContainsKey('Detail')) { $params.Detail = $Detail } + if ($PSBoundParameters.ContainsKey('Percent') -and $null -ne $Percent) { $params.Percent = $Percent } + if ($PSBoundParameters.ContainsKey('Item')) { $params.Item = $Item } + if ($PSBoundParameters.ContainsKey('ItemState')) { $params.ItemState = $ItemState } + if ($PSBoundParameters.ContainsKey('ItemDetail')) { $params.ItemDetail = $ItemDetail } + if ($PSBoundParameters.ContainsKey('ExitCode') -and $null -ne $ExitCode) { $params.ExitCode = $ExitCode } + $null = Set-SPSUpdateStatus @params + } + catch { + Write-Warning -Message "Failed to write patching status ($Scope): $($_.Exception.Message)" + } +} + +# Local helper: (re)generate the live dashboard from the status store. +function Write-SPSDashboard { + param([switch] $Completed) + if ([string]::IsNullOrEmpty($statusCampaignPath)) { return } + try { + $params = @{ + CampaignPath = $statusCampaignPath + OutputFile = $statusDashboardPath + EnvName = $Environment + AppCode = $Application + FarmName = $spFarmName + } + if ($Completed) { $params.Completed = $true } + $null = Export-SPSUpdateProgressReport @params + } + catch { + Write-Warning -Message "Failed to generate patching dashboard: $($_.Exception.Message)" + } +} + # 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 { @@ -338,6 +406,33 @@ Exception: $_ # 3. Execute Action parameter switch ($Action) { + 'ResetStatus' { + # Clear the status store campaign folder so a fresh patching round starts clean. + # Run this once at the start of a campaign before launching ProductUpdate on the + # servers and the master Default run. + try { + if ([string]::IsNullOrEmpty($statusCampaignPath)) { + Write-Warning -Message 'No status store campaign path resolved; nothing to reset.' + } + elseif (Test-Path -Path $statusCampaignPath) { + Write-Output "Resetting patching status store campaign: $statusCampaignPath" + Get-ChildItem -Path $statusCampaignPath -File -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + Write-Output 'Status store campaign cleared.' + } + else { + Write-Output "Status store campaign folder does not exist yet (nothing to reset): $statusCampaignPath" + } + } + catch { + $catchMessage = @" +Failed to reset the status store campaign: $($statusCampaignPath) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Set-SPSUpdateStatus' -EntryType 'Error' + } + } 'InitContentDB' { # (Re)generate the ContentDatabase inventory JSON file for the local farm. # Typically used on a source farm (for example SP2019) to prepare an upgrade @@ -531,6 +626,8 @@ Exception: $_ } 'ProductUpdate' { # Run ProductUpdate + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -State 'Running' -Detail "Installing $(@($envCfg.Binaries.SetupFileName).Count) update(s)" + Write-SPSDashboard try { foreach ($setupFile in $envCfg.Binaries.SetupFileName) { $fullSetupFilePath = Join-Path -Path $envCfg.Binaries.SetupFullPath -ChildPath $setupFile @@ -541,6 +638,8 @@ SharePoint Server: $($spTargetServer) Setup File Path: $($fullSetupFilePath) Shutdown Services: $($envCfg.Binaries.ShutdownServices) "@ + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -State 'Running' -Item $setupFile -ItemState 'Running' -ItemDetail 'installing' + Write-SPSDashboard # NOTE: Pending reboot detection was removed because on production farms the # Windows reboot markers (CBS, PendingFileRenameOperations, etc.) commonly # remain set after several reboots, which caused the script to abort the @@ -548,7 +647,11 @@ Shutdown Services: $($envCfg.Binaries.ShutdownServices) # Unblock setup file if it is blocked Unblock-File -Path $fullSetupFilePath -Verbose Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $envCfg.Binaries.ShutdownServices -Verbose + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Item $setupFile -ItemState 'Done' -ItemDetail 'installed' + Write-SPSDashboard } + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -State 'Done' -Detail 'All updates processed' + Write-SPSDashboard } catch { # Handle errors during Run ProductUpdate @@ -559,6 +662,8 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -State 'Failed' -Detail "$($_.Exception.Message)" + Write-SPSDashboard if ($script:TranscriptStarted) { Stop-Transcript | Out-Null $script:TranscriptStarted = $false @@ -568,6 +673,8 @@ Exception: $_ } Default { if ($PSBoundParameters.ContainsKey('Sequence')) { + $seqScope = "Sequence$Sequence" + $seqPhase = if ($envCfg.MountContentDatabase) { 'Mount' } else { 'Upgrade' } try { Write-Output "Update Script in progress | Sequence $Sequence - Please Wait ..." switch ($Sequence) { @@ -576,7 +683,11 @@ Exception: $_ 3 { $dbs = $jsonDbCfg.SPContentDatabase3 } 4 { $dbs = $jsonDbCfg.SPContentDatabase4 } } - foreach ($db in $dbs) { + $dbList = @($dbs) + $dbTotal = $dbList.Count + $dbDone = 0 + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -State 'Running' -Percent 0 -Detail "$dbTotal database(s)" + foreach ($db in $dbList) { # Mount SPContentDatabase (typically used to attach databases coming from a # previous farm version, for example SP2019 -> Subscription Edition migration). # Mounts run inside each Sequence scheduled task, so the 4 sequences process @@ -584,11 +695,14 @@ Exception: $_ # the ContentDatabase inventory JSON file produced by Initialize-SPSContentDbJsonFile. # Mount and Upgrade are independent: a farm can be configured for Mount only, # Upgrade only, or both (Mount then Upgrade for SP2019 -> SE migration). + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -Item "$($db.Name)" -ItemState 'Running' -ItemDetail 'processing' + $dbFailed = $false if ($envCfg.MountContentDatabase) { try { Mount-SPSContentDatabase -Name $db.Name -WebAppUrl $db.WebAppUrl -DatabaseServer $db.Server } catch { + $dbFailed = $true $catchMessage = @" Failed to Mount SPContentDatabase '$($db.Name)' on WebApplication '$($db.WebAppUrl)' Target SPFarm: $($spFarmName) @@ -596,12 +710,19 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Mount-SPSContentDatabase' -EntryType 'Error' + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -Item "$($db.Name)" -ItemState 'Failed' -ItemDetail "Mount failed: $($_.Exception.Message)" } } - if ($envCfg.UpgradeContentDatabase) { + if (-not $dbFailed -and $envCfg.UpgradeContentDatabase) { Update-SPSContentDatabase -Name $db.Name } + if (-not $dbFailed) { + $dbDone++ + $pct = if ($dbTotal -gt 0) { [int]([math]::Round($dbDone / $dbTotal * 100, 0)) } else { 100 } + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -State 'Running' -Percent $pct -Item "$($db.Name)" -ItemState 'Done' -ItemDetail 'processed' + } } + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -State 'Done' -Percent 100 -Detail "$dbDone/$dbTotal processed" } catch { # Handle errors during Update Script Sequence @@ -612,6 +733,7 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Update-SPSContentDatabase' -EntryType 'Error' + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -State 'Failed' -Detail "$($_.Exception.Message)" } } else { @@ -731,6 +853,9 @@ Exception: $_ $allTasksFinished = $false } } + # Refresh the live dashboard from the shared status store while the + # parallel sequence tasks write their progress into it. + Write-SPSDashboard if (-not $allTasksFinished) { Write-Output 'At least one task is still running. Waiting...' Start-Sleep -Seconds 10 @@ -745,11 +870,16 @@ Exception: $_ Write-Output "Getting Patch Status on server: $($env:COMPUTERNAME)" if ((Get-SPSServersPatchStatus -Server "$($env:COMPUTERNAME)") -eq 'NoActionRequired') { Write-Output "No Action Required on server: $($env:COMPUTERNAME). Skipping Configuration Wizard." + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Skipped' -Detail 'No action required' } else { Write-Output "Action Required on server: $($env:COMPUTERNAME). Proceeding to run Configuration Wizard." + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Running' -Detail 'Running PSConfig' + Write-SPSDashboard Start-SPSConfigExe + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Done' -Detail 'PSConfig completed' } + Write-SPSDashboard } catch { # Handle errors during Run SPConfigWizard on Master SharePoint Server @@ -761,6 +891,8 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSConfigExe' -EntryType 'Error' + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Failed' -Detail "$($_.Exception.Message)" + Write-SPSDashboard } # Run SPConfigWizard on other SharePoint Server @@ -771,12 +903,17 @@ Exception: $_ Write-Output "Getting Patch Status on server: $($spServer.Name)" if ((Get-SPSServersPatchStatus -Server "$($spServer.Name)") -eq 'NoActionRequired') { Write-Output "No Action Required on server: $($spServer.Name). Skipping Configuration Wizard." + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Skipped' -Detail 'No action required' } else { Write-Output "Action Required on server: $($spServer.Name). Proceeding to run Configuration Wizard." $spTargetServer = "$($spServer.Name).$($scriptFQDN)" + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Running' -Detail 'Running PSConfig (remote)' + Write-SPSDashboard Start-SPSConfigExeRemote -Server $spTargetServer -InstallAccount $credential + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Done' -Detail 'PSConfig completed' } + Write-SPSDashboard } catch { # Handle errors during Run SPConfigWizard on remote SharePoint Server @@ -788,6 +925,8 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSConfigExeRemote' -EntryType 'Error' + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Failed' -Detail "$($_.Exception.Message)" + Write-SPSDashboard } } @@ -795,7 +934,11 @@ Exception: $_ if (-not([string]::IsNullOrEmpty($envCfg.SideBySideToken.BuildVersion))) { try { Write-Output "Configuring SharePoint SideBySideToken on farm $($spFarmName)" + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server $thisServer -State 'Running' -Detail "Token $($envCfg.SideBySideToken.BuildVersion)" + Write-SPSDashboard Set-SPSSideBySideToken -BuildVersion "$($envCfg.SideBySideToken.BuildVersion)" -EnableSideBySide $envCfg.SideBySideToken.Enable + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server $thisServer -State 'Done' -Detail 'Token configured' + Write-SPSDashboard } catch { # Handle errors during Run Set-SPSSideBySideToken @@ -806,6 +949,8 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Set-SPSSideBySideToken' -EntryType 'Error' + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server $thisServer -State 'Failed' -Detail "$($_.Exception.Message)" + Write-SPSDashboard } } @@ -816,6 +961,7 @@ Exception: $_ try { $spTargetServer = "$($spServer.Name).$($scriptFQDN)" Copy-SPSSideBySideFilesRemote -Server $spTargetServer -InstallAccount $credential + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server "$($spServer.Name)" -State 'Done' -Detail 'Side-by-side files copied' } catch { # Handle errors during Run Copy-SPSSideBySideFilesAllServers @@ -827,9 +973,13 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Copy-SPSSideBySideFiles' -EntryType 'Error' + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server "$($spServer.Name)" -State 'Failed' -Detail "$($_.Exception.Message)" } } } + + # Final dashboard render: mark the campaign completed (auto-refresh off). + Write-SPSDashboard -Completed } } } From ef2998195c2a8d15dc478d1a9ebe603c397ae7bb Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 16:44:53 +0200 Subject: [PATCH 05/15] docs: document the live dashboard + readiness for v4.2.0 - Bump module manifest to 4.2.0 (test guard updated). - wiki/Usage.md: 'Near real-time patching dashboard' + 'Pre-flight readiness check' sections (StatusStorePath, ResetStatus, campaign workflow). - wiki/Configuration.md: StatusStorePath reference. - src/SPSUpdate_README.md (offline guide): dashboard + readiness sections. - CHANGELOG.md [4.2.0] and RELEASE-NOTES.md (release body). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 31 +++++++++ RELEASE-NOTES.md | 41 ++++++++---- .../SPSUpdate.Common/SPSUpdate.Common.psd1 | 2 +- src/SPSUpdate_README.md | 21 ++++++ tests/SPSUpdate.Common.Tests.ps1 | 2 +- wiki/Configuration.md | 16 +++++ wiki/Usage.md | 65 +++++++++++++++++++ 7 files changed, 163 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fade11c..5014967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,37 @@ 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.2.0] - 2026-06-29 + +### Added + +- Near real-time patching dashboard. New public functions `Set-SPSUpdateStatus` / + `Get-SPSUpdateStatus` persist and read a shared, per-scope JSON status store + (atomic writes, each writer owns its file), `Get-SPSStatusCampaignPath` resolves + the campaign folder, and `Export-SPSUpdateProgressReport` renders a self-contained, + auto-refreshing HTML dashboard (overall state, per-phase sections, colored state + badges, per-item exit codes and per-sequence percentage). +- New optional `StatusStorePath` config key (UNC share) for the status store, with a + local `Results\status` fallback. +- New `-Action ResetStatus` to clear a campaign before a fresh patching round. +- `SPSUpdate.ps1` is instrumented to feed the store and regenerate the dashboard: + ProductUpdate per server (per-setup-file items), the four mount/upgrade sequences + (per-database items and a running percentage), the Configuration Wizard per server + (local and remote), and the side-by-side step. The master regenerates the dashboard + on every wait-loop iteration and writes a final completed dashboard. +- New `Test-SPSUpdateReadiness.ps1` pre-flight check (module, config, DPAPI secret, + elevation, status store write access, per-server CredSSP reachability). + +### Changed + +- Bumped the module manifest to `4.2.0` and exported the four new functions. +- Extended the shared HTML head helper with an optional meta-refresh and state-badge styles. + +### Tests + +- Added cross-platform Pester suites for the status store round-trip and resilience, and + for the dashboard (running / failed / completed / empty / HTML-encoding). + ## [4.1.0] - 2026-06-29 ### Added diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 4e76492..81b9923 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,24 +1,39 @@ # SPSUpdate - Release Notes -## [4.1.0] - 2026-06-29 +## [4.2.0] - 2026-06-29 -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. +This release adds a near real-time patching dashboard: while a cumulative update is rolled +out across the farm, SPSUpdate records the progress of every phase into a shared status +store and the master assembles a self-contained, auto-refreshing HTML dashboard. ### Added -- New public function `Export-SPSUpdateDbReport` that renders the ContentDatabase inventory (`---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`. +- Status store and dashboard functions: `Set-SPSUpdateStatus` / `Get-SPSUpdateStatus` + (atomic per-scope JSON store, each writer owns its file), `Get-SPSStatusCampaignPath` + (resolves `\--`), and `Export-SPSUpdateProgressReport` (renders a + self-contained HTML dashboard with overall state, per-phase sections, colored state + badges, per-item exit codes and per-sequence percentage; meta-refresh while running). +- New optional `StatusStorePath` config key (UNC share) with a local `Results\status` + fallback. +- New `-Action ResetStatus` to clear a campaign before a fresh patching round. +- `SPSUpdate.ps1` is instrumented end-to-end: ProductUpdate per server (per-setup-file + items), the four mount/upgrade sequences (per-database items and a running percentage), + the Configuration Wizard per server (local and remote), and side-by-side. The master + regenerates the dashboard on every wait-loop iteration and writes a final completed + dashboard with auto-refresh off. +- New `Test-SPSUpdateReadiness.ps1` pre-flight check (module, config, DPAPI secret, + elevation, status store write access, per-server CredSSP reachability). + +### How to use + +1. Set `StatusStorePath` to a UNC share writable by the InstallAccount from every server. +2. `SPSUpdate.ps1 -ConfigFile '.psd1' -Action ResetStatus` to start a clean campaign. +3. Open `\--\_dashboard.html` in a browser. +4. Run `-Action ProductUpdate` on each server, then the default master run; watch the + dashboard update itself. ### 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`. - -### Notes - -- 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. +- Bumped the module manifest to `4.2.0` and exported the four new functions. A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 index defae45..b2fb943 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.1.0' + ModuleVersion = '4.2.0' GUID = 'd6f4e2b7-3a1c-4d8e-9f2a-6c5b7e0a1d34' Author = 'Jean-Cyril DROUHIN' CompanyName = 'luigilink' diff --git a/src/SPSUpdate_README.md b/src/SPSUpdate_README.md index ddeb96d..4e11cb2 100644 --- a/src/SPSUpdate_README.md +++ b/src/SPSUpdate_README.md @@ -161,6 +161,27 @@ and configures the side-by-side token when enabled: E:\SCRIPT\SPSUpdate.ps1 -ConfigFile 'E:\SCRIPT\Config\CONTOSO-PROD-CONTENT.psd1' ``` +## 📡 Near real-time dashboard (optional) + +Set `StatusStorePath` in the config to a UNC share writable by the InstallAccount from +every server to get a live HTML dashboard of the patching campaign: + +1. `SPSUpdate.ps1 -ConfigFile '.psd1' -Action ResetStatus` (clears the campaign). +2. Open `\--\_dashboard.html` in a browser (auto-refresh). +3. Run `-Action ProductUpdate` on each server, then the default master run. + +If `StatusStorePath` is empty, the dashboard falls back to the local `Results\status` +folder (ProductUpdate on other servers is then not captured centrally). + +## ✅ Pre-flight readiness check (optional) + +```powershell +E:\SCRIPT\Test-SPSUpdateReadiness.ps1 -ConfigFile 'E:\SCRIPT\Config\CONTOSO-PROD-CONTENT.psd1' +``` + +Read-only validation of the module, config, DPAPI secret, elevation, status store write +access and CredSSP reachability. Exits non-zero on any failure. + ## 🔄 Uninstalling To remove the scheduled tasks and the stored secret: diff --git a/tests/SPSUpdate.Common.Tests.ps1 b/tests/SPSUpdate.Common.Tests.ps1 index ce1b772..8905e09 100644 --- a/tests/SPSUpdate.Common.Tests.ps1 +++ b/tests/SPSUpdate.Common.Tests.ps1 @@ -37,7 +37,7 @@ Describe 'SPSUpdate.Common module' { } It 'manifest version is 4.0.0 or higher' { - (Test-ModuleManifest -Path $modulePath).Version | Should -BeGreaterOrEqual ([version]'4.0.0') + (Test-ModuleManifest -Path $modulePath).Version | Should -BeGreaterOrEqual ([version]'4.2.0') } It 'exports exactly the expected public functions' { diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 12756da..f6e42ea 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -127,6 +127,22 @@ by the side-by-side token. Zero downtime patching is a method of patching and up developed in SharePoint in Microsoft 365. For more details see [SharePoint Server zero downtime patching steps](https://learn.microsoft.com/en-us/sharepoint/upgrade-and-update/sharepoint-server-2016-zero-downtime-patching-steps). +## StatusStorePath (live dashboard) + +`StatusStorePath` is an OPTIONAL UNC share where every farm server writes its patching +progress so the master can assemble the near-real-time HTML dashboard. It must be writable +by the InstallAccount from every server. + +```powershell +StatusStorePath = '\\fileserver\spsupdate-status' +``` + +Leave it empty (or omit it) to fall back to a local `Results\status` folder; in that case +ProductUpdate runs launched on the other servers are not captured centrally. The status +files of one campaign live under `\--\`, with the +dashboard written there as `_dashboard.html`. See the +[Usage](./Usage) page for the campaign workflow and the `ResetStatus` action. + ## Next Step For the next steps, go to the [Usage](./Usage) page. diff --git a/wiki/Usage.md b/wiki/Usage.md index 641373e..98599eb 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -122,6 +122,71 @@ dedicated **`SPSUpdate` Windows Event Log** via `Add-SPSUpdateEvent`. - Test the script in a non-production environment before deploying it widely. +## Near real-time patching dashboard + +When a status store is configured, SPSUpdate records the progress of every phase +(ProductUpdate per server, the four mount/upgrade sequences, the Configuration Wizard +per server, and side-by-side) into a shared location, and the master assembles a live +HTML dashboard you can open in a browser while patching runs. + +### Configure the status store + +Set `StatusStorePath` in the environment config to a UNC share writable by the +InstallAccount from every farm server: + +```powershell +StatusStorePath = '\\fileserver\spsupdate-status' +``` + +If left empty, SPSUpdate falls back to a local `Results\status` folder. In that case the +master still shows the mount/upgrade/wizard phases, but ProductUpdate runs launched on the +other servers are not captured centrally (they would each write to their own local folder). + +The status files of one patching campaign live under +`\--\`, and the dashboard is written there as +`_dashboard.html`. + +### Run a patched campaign with the dashboard + +1. **Reset** the campaign once (clears any previous run): + + ```powershell + .\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action ResetStatus + ``` + +2. Open `\\--\_dashboard.html` in a browser. It + auto-refreshes every 15 seconds while the campaign is in progress. + +3. Install the binaries on **each** server (each writes its ProductUpdate progress to the + share): + + ```powershell + .\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action ProductUpdate + ``` + +4. Run the master upgrade on the master server. It regenerates the dashboard as the four + sequences, the Configuration Wizard and the side-by-side step progress, and turns the + auto-refresh off with a final state when finished: + + ```powershell + .\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + ``` + +The dashboard shows the overall state, per-server / per-sequence progress, per-database and +per-binary item states with exit codes, and a completion percentage for each sequence. + +## Pre-flight readiness check + +`Test-SPSUpdateReadiness.ps1` validates the environment before a run (read-only). It checks +the module import, the config keys, the DPAPI secret, elevation, the status store +reachability and write access, and the per-server WinRM/CredSSP reachability: + +```powershell +.\Test-SPSUpdateReadiness.ps1 -ConfigFile 'Config\CONTOSO-PROD-CONTENT.psd1' +``` + +It exits non-zero when any check fails, so it can gate an automated rollout. + ## Support For issues or questions, open an [issue](https://github.com/luigilink/SPSUpdate/issues) or From 8dd0921df6e1bb15922e82528d400e22aefbc4fb Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 18:44:47 +0200 Subject: [PATCH 06/15] docs: align ShutdownServices example value with its documented default The example config commented 'Default if omitted: $true' but set the value to $false. Set it to $true so the template matches the documented default and the recommended behaviour. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Config/CONTOSO-PROD.example.psd1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config/CONTOSO-PROD.example.psd1 b/src/Config/CONTOSO-PROD.example.psd1 index b9e297a..4fec29f 100644 --- a/src/Config/CONTOSO-PROD.example.psd1 +++ b/src/Config/CONTOSO-PROD.example.psd1 @@ -69,7 +69,7 @@ # ShutdownServices : stop Search/Timer/IIS services during install to speed it up # (they are restored to their prior state afterwards). # Possible values : $true | $false. Default if omitted: $true - ShutdownServices = $false + ShutdownServices = $true } # --- Content database handling (OPTIONAL) ---------------------------------------- From 250780944af5098aa015cf3cd05b85fd20b5d59e Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Mon, 29 Jun 2026 18:49:02 +0200 Subject: [PATCH 07/15] feat: ResetStatus creates the campaign folder and an empty dashboard ResetStatus now creates the campaign folder (when missing) and generates the empty 'waiting for the first update' dashboard immediately, so the operator can open _dashboard.html in a browser before launching the ProductUpdate runs and the master Default run, and watch it fill up live. Previously the folder and dashboard only appeared on the first status write. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SPSUpdate.ps1 | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index 7ad077d..7fafeb9 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -407,21 +407,29 @@ Exception: $_ # 3. Execute Action parameter switch ($Action) { 'ResetStatus' { - # Clear the status store campaign folder so a fresh patching round starts clean. - # Run this once at the start of a campaign before launching ProductUpdate on the - # servers and the master Default run. + # Clear the status store campaign folder so a fresh patching round starts clean, + # then create the folder and an empty "waiting" dashboard so it can be opened in a + # browser before the ProductUpdate runs and the master Default run begin. try { if ([string]::IsNullOrEmpty($statusCampaignPath)) { Write-Warning -Message 'No status store campaign path resolved; nothing to reset.' } - elseif (Test-Path -Path $statusCampaignPath) { - Write-Output "Resetting patching status store campaign: $statusCampaignPath" - Get-ChildItem -Path $statusCampaignPath -File -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - Write-Output 'Status store campaign cleared.' - } else { - Write-Output "Status store campaign folder does not exist yet (nothing to reset): $statusCampaignPath" + if (Test-Path -Path $statusCampaignPath) { + Write-Output "Resetting patching status store campaign: $statusCampaignPath" + Get-ChildItem -Path $statusCampaignPath -File -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + Write-Output 'Status store campaign cleared.' + } + else { + Write-Output "Creating patching status store campaign: $statusCampaignPath" + New-Item -Path $statusCampaignPath -ItemType Directory -Force | Out-Null + } + # Generate the empty dashboard now so it is ready to open before patching. + Write-SPSDashboard + if (-not [string]::IsNullOrEmpty($statusDashboardPath)) { + Write-Output "Live dashboard ready (open it in a browser): $statusDashboardPath" + } } } catch { From 54378d75be31e33591d4cda796a933bd99dd512b Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 30 Jun 2026 14:03:35 +0200 Subject: [PATCH 08/15] fix: dashboard counts each unit of work once (no scope+item double count) The Done/Running/Failed cards summed both the scope-level state and each of its item states, so two servers each running one ProductUpdate binary showed '4 Done' instead of '2'. Roll up leaf units only: count the items when a scope has any (each binary, each database), otherwise the scope state itself (e.g. the Wizard, which has a per-server state but no items). Add tests locking the no-double-count behaviour for item-bearing and item-less scopes. 163 Pester tests passing; PSScriptAnalyzer clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Public/Export-SPSUpdateProgressReport.ps1 | 12 +++++++++-- .../Export-SPSUpdateProgressReport.Tests.ps1 | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 index edcc1d1..11475fb 100644 --- a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 @@ -109,10 +109,18 @@ } # ---- Overall roll-up ---------------------------------------------------------- + # Count each leaf unit of work once: the items when a scope has any (each binary, + # each database), otherwise the scope state itself (e.g. the Wizard, which carries a + # per-server state but no items). This avoids double counting a scope and its items. $allItemStates = New-Object System.Collections.Generic.List[string] foreach ($sc in $scopes) { - if ($sc.State) { $allItemStates.Add("$($sc.State)") } - foreach ($it in @($sc.Items)) { if ($it.State) { $allItemStates.Add("$($it.State)") } } + $scopeItems = @($sc.Items) + if ($scopeItems.Count -gt 0) { + foreach ($it in $scopeItems) { if ($it.State) { $allItemStates.Add("$($it.State)") } } + } + elseif ($sc.State) { + $allItemStates.Add("$($sc.State)") + } } $countFailed = @($allItemStates | Where-Object { $_ -eq 'Failed' }).Count $countRunning = @($allItemStates | Where-Object { $_ -eq 'Running' }).Count diff --git a/tests/Export-SPSUpdateProgressReport.Tests.ps1 b/tests/Export-SPSUpdateProgressReport.Tests.ps1 index afae044..ac71388 100644 --- a/tests/Export-SPSUpdateProgressReport.Tests.ps1 +++ b/tests/Export-SPSUpdateProgressReport.Tests.ps1 @@ -66,6 +66,26 @@ Describe 'Export-SPSUpdateProgressReport (running campaign)' { } } +Describe 'Export-SPSUpdateProgressReport (roll-up counts each unit once)' { + It 'does not double-count a scope and its single item' { + $camp = New-Campaign -Name 'count' + # Two servers, each a ProductUpdate scope with exactly one binary item, all Done. + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'APP1' -State 'Done' -Item 'uber.exe' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'SCH1' -State 'Done' -Item 'uber.exe' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + $h = Get-Content -Path (Export-SPSUpdateProgressReport -CampaignPath $camp) -Raw + # The 'Done' card must read 2 (one per server), not 4 (scope + item per server). + $h | Should -Match '
2
Done
' + } + + It 'counts an item-less scope (wizard) once via its scope state' { + $camp = New-Campaign -Name 'count-wizard' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Wizard' -Phase 'Wizard' -Server 'APP1' -State 'Done' -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Wizard' -Phase 'Wizard' -Server 'SCH1' -State 'Done' -Confirm:$false | Out-Null + $h = Get-Content -Path (Export-SPSUpdateProgressReport -CampaignPath $camp) -Raw + $h | Should -Match '
2
Done
' + } +} + Describe 'Export-SPSUpdateProgressReport (states)' { It 'reports Failed overall when any item failed' { $camp = New-Campaign -Name 'fail' From 9ce9343aeffadbad674d212fcfb39bd67d1d7d29 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 30 Jun 2026 14:09:44 +0200 Subject: [PATCH 09/15] fix: keep the dashboard live during the staggered sequence start The master started the four sequence tasks one by one with a single 60-90s Start-Sleep between each (to avoid OWSTimer conflicts) and only regenerated the dashboard once it reached the wait loop. During that staggered start (~4-6 min) a running sequence wrote its status but the dashboard did not refresh, so the upgrade section appeared late. Now regenerate the dashboard right after each task start and every ~10s during the pause, so per-database progress shows up immediately. 163 Pester tests passing; PSScriptAnalyzer clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SPSUpdate.ps1 | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index 7fafeb9..d517d61 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -819,8 +819,21 @@ Exception: $_ Write-Output "Running Scheduled Task $taskName in $script:TaskPath Task Path" $startResult = Start-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath -ErrorAction Stop Write-Output "Start requested for $($startResult.Name) in $($startResult.TaskPath). Current state: $($startResult.State)" - Write-Output 'Avoid conflicts with OWSTimer process - Pause between 60 to 90 seconds' - Start-Sleep -Seconds (get-random (60..90)) + # Refresh the dashboard right after each start so a sequence that + # already began writing its status shows up immediately. + Write-SPSDashboard + # Pause 60-90s between starts to avoid OWSTimer conflicts, but keep + # the dashboard live by regenerating it every ~10s during the wait + # (the started sequences write their per-database progress meanwhile). + $pauseSeconds = (Get-Random -Minimum 60 -Maximum 91) + Write-Output "Avoid conflicts with OWSTimer process - Pause $pauseSeconds seconds" + $waited = 0 + while ($waited -lt $pauseSeconds) { + $chunk = [System.Math]::Min(10, ($pauseSeconds - $waited)) + Start-Sleep -Seconds $chunk + $waited += $chunk + Write-SPSDashboard + } } catch { # Handle errors during Start scheduled Task for Upgrade SPContentDatabase in Parallel From be08b96b3faa2fc07ba814c7729a68928fd653fa Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 30 Jun 2026 14:16:28 +0200 Subject: [PATCH 10/15] feat: readiness probes the InstallAccount's write access to the status store The upgrade/mount sequence tasks run as the InstallAccount (the scheduled-task service account), not the interactive user. When that account lacks write access to the UNC status store, the sequences silently fail to publish their status and the dashboard never shows the upgrade phase (only ProductUpdate and Wizard, written by the interactive/master run, appear) -- exactly the symptom observed on a real farm. - Test-SPSUpdateReadiness.ps1 now probes write access twice: as the current user, and as the InstallAccount (launches a short process under its credential and verifies the file lands on the share). A missing service- account write is a FAIL with remediation guidance. - Document the required share permission (InstallAccount needs Modify on the SMB share + NTFS) in Getting-Started, Configuration, Usage and the offline guide. CHANGELOG updated (also records the ResetStatus/empty-dashboard, staggered-start live refresh, and the roll-up double-count fix). PSScriptAnalyzer clean; 163 Pester tests passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 19 +++++++++++++- src/SPSUpdate_README.md | 6 +++++ src/Test-SPSUpdateReadiness.ps1 | 45 +++++++++++++++++++++++++++++++-- wiki/Configuration.md | 10 ++++++++ wiki/Getting-Started.md | 1 + wiki/Usage.md | 11 ++++++++ 6 files changed, 89 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5014967..04ca1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,12 +22,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (local and remote), and the side-by-side step. The master regenerates the dashboard on every wait-loop iteration and writes a final completed dashboard. - New `Test-SPSUpdateReadiness.ps1` pre-flight check (module, config, DPAPI secret, - elevation, status store write access, per-server CredSSP reachability). + elevation, status store write access, per-server CredSSP reachability). The status store + check probes write access **both as the current user and as the InstallAccount** (the + account that runs the sequence tasks), since the upgrade sequences only publish their + status — and therefore only appear on the dashboard — when the service account can write + to the share. +- `-Action ResetStatus` now creates the campaign folder and an empty "waiting" dashboard so + it can be opened in a browser before patching begins. +- The master keeps the dashboard live during the staggered sequence start (regenerated after + each task start and every ~10s during the 60-90s OWSTimer pause), not only once the wait + loop is reached. ### Changed - Bumped the module manifest to `4.2.0` and exported the four new functions. - Extended the shared HTML head helper with an optional meta-refresh and state-badge styles. +- Documented that the InstallAccount needs **Modify** on the status store share (SMB + NTFS) + for the upgrade phase to appear on the dashboard (Getting-Started, Configuration, Usage and + the offline guide). + +### Fixed + +- The dashboard roll-up counted each unit of work once instead of summing both a scope and + its items (e.g. two servers each installing one binary now read "2 Done", not "4"). ### Tests diff --git a/src/SPSUpdate_README.md b/src/SPSUpdate_README.md index 4e11cb2..4b19b62 100644 --- a/src/SPSUpdate_README.md +++ b/src/SPSUpdate_README.md @@ -173,6 +173,12 @@ every server to get a live HTML dashboard of the patching campaign: If `StatusStorePath` is empty, the dashboard falls back to the local `Results\status` folder (ProductUpdate on other servers is then not captured centrally). +> IMPORTANT: grant the InstallAccount (the scheduled-task service account) **Modify** on the +> share (SMB share + NTFS). The upgrade/mount sequence tasks run as that account; without +> write access the upgrade phase will not appear on the dashboard. Run +> `Test-SPSUpdateReadiness.ps1` to verify it (it probes write access as both your account and +> the InstallAccount). + ## ✅ Pre-flight readiness check (optional) ```powershell diff --git a/src/Test-SPSUpdateReadiness.ps1 b/src/Test-SPSUpdateReadiness.ps1 index a6b6c55..7df2a99 100644 --- a/src/Test-SPSUpdateReadiness.ps1 +++ b/src/Test-SPSUpdateReadiness.ps1 @@ -205,10 +205,51 @@ if ($null -ne $cfg -and $cfg.Contains('StatusStorePath') -and -not [string]::IsN try { Set-Content -Path $probe -Value 'readiness' -ErrorAction Stop Remove-Item -Path $probe -Force -ErrorAction SilentlyContinue - Add-CheckResult -Section 'StatusStore' -Name 'Status store writable' -Status 'PASS' -Detail $storePath + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (current user)' -Status 'PASS' -Detail $storePath } catch { - Add-CheckResult -Section 'StatusStore' -Name 'Status store writable' -Status 'FAIL' -Detail "Cannot write to $storePath : $($_.Exception.Message)" + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (current user)' -Status 'FAIL' -Detail "Cannot write to $storePath : $($_.Exception.Message)" + } + + # CRITICAL: the four upgrade/mount sequence tasks run as the InstallAccount (the + # scheduled-task service account), not as the interactive user. If that account + # cannot write to the share, the sequences silently fail to publish their status + # and the dashboard never shows the upgrade phase. Probe write access AS the + # InstallAccount by launching a short process under its credential and checking + # whether the file actually lands on the share. + $svcCred = $null + if ($null -ne $cfg -and $cfg.Contains('CredentialKey') -and $cfg.CredentialKey -and (Get-Command -Name Get-SPSSecret -ErrorAction SilentlyContinue)) { + $configFolder2 = Split-Path -Path $ConfigFile -Parent + if ([string]::IsNullOrEmpty($configFolder2)) { $configFolder2 = '.' } + try { $svcCred = Get-SPSSecret -CredentialKey $cfg.CredentialKey -ConfigPath $configFolder2 -ErrorAction Stop } catch { $svcCred = $null } + } + + if ($null -eq $svcCred) { + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (service account)' -Status 'WARN' -Detail 'Could not load the InstallAccount to test; ensure it has Modify on the share (the sequence tasks run as that account)' + } + else { + $svcProbe = Join-Path -Path $storePath -ChildPath (".spsupdate-readiness-svc-{0}.tmp" -f ([guid]::NewGuid().ToString('N'))) + $svcCmd = "Set-Content -LiteralPath '$svcProbe' -Value 'readiness-svc' -ErrorAction Stop" + $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($svcCmd)) + try { + $proc = Start-Process -FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" ` + -Credential $svcCred ` + -WorkingDirectory "$env:SystemRoot" ` + -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', $encoded) ` + -Wait -PassThru -ErrorAction Stop + $null = $proc + Start-Sleep -Milliseconds 500 + if (Test-Path -Path $svcProbe) { + Remove-Item -Path $svcProbe -Force -ErrorAction SilentlyContinue + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (service account)' -Status 'PASS' -Detail "InstallAccount '$($svcCred.UserName)' can write to the share" + } + else { + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (service account)' -Status 'FAIL' -Detail "InstallAccount '$($svcCred.UserName)' cannot write to $storePath. Grant it Modify on the share + NTFS; otherwise the upgrade sequences will not appear on the dashboard." + } + } + catch { + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (service account)' -Status 'WARN' -Detail "Could not launch a probe as '$($svcCred.UserName)' ($($_.Exception.Message)). Verify it has 'Log on as a batch job' and Modify on the share." + } } } } diff --git a/wiki/Configuration.md b/wiki/Configuration.md index f6e42ea..259c864 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -143,6 +143,16 @@ files of one campaign live under `\--\`, with t dashboard written there as `_dashboard.html`. See the [Usage](./Usage) page for the campaign workflow and the `ResetStatus` action. +### Required permissions + +Grant the **InstallAccount** (the account that runs the scheduled tasks) **Modify** rights +on the share — both the SMB **share** permission and the **NTFS** permission. The four +upgrade/mount sequence tasks run as that account, so if it cannot write to the share the +upgrade phase never appears on the dashboard (the ProductUpdate and Wizard sections, written +by your interactive/master run under your own account, still show — which can hide the +problem). Run `Test-SPSUpdateReadiness.ps1` to verify both your account and the InstallAccount +can write to the store before patching. + ## Next Step For the next steps, go to the [Usage](./Usage) page. diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index 33b9aac..c9ef384 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -7,6 +7,7 @@ - Administrative privileges on each SharePoint Server - A service account (`InstallAccount`) whose credential is stored in `Config\secrets.psd1` - SharePoint update binaries copied to a local or accessible path (if using `ProductUpdate`) +- (Optional, for the live dashboard) a UNC share for the status store with **Modify** rights for the InstallAccount — see [Configuration](./Configuration#statusstorepath-live-dashboard) ## Configure CredSSP diff --git a/wiki/Usage.md b/wiki/Usage.md index 98599eb..812919a 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -146,6 +146,17 @@ The status files of one patching campaign live under `\--\`, and the dashboard is written there as `_dashboard.html`. +> [!IMPORTANT] +> **The InstallAccount (the scheduled-task service account) must have Modify rights on +> the share — both the SMB share permission and the NTFS permission.** The four +> upgrade/mount sequence tasks run as that account, so if it cannot write to the store the +> sequences silently fail to publish their status and the **Content database upgrade phase +> never appears on the dashboard** (only the ProductUpdate and Wizard sections, written by +> the interactive/master run, show up). The ProductUpdate and Default runs you launch +> interactively write under your own account, which can mask the problem during testing. +> `Test-SPSUpdateReadiness.ps1` probes write access both as the current user **and** as the +> InstallAccount to catch this up front. + ### Run a patched campaign with the dashboard 1. **Reset** the campaign once (clears any previous run): From b1b5f6f029d4f9964dbfa992c7f73297f9cb3cc9 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 30 Jun 2026 14:26:12 +0200 Subject: [PATCH 11/15] fix: overall state reflects scope progress, not just leaf items After the no-double-count fix, the overall state was derived only from leaf item states, so a scope still Running whose recorded items all happened to be Done (e.g. Sequence1 during the staggered start, before sequences 2-4 begin and before the Wizard) flipped the overall badge to 'Done' prematurely. Separate the two concerns: card counts stay leaf-unit based (no double count), while the overall state now considers both scope states and item states, so it stays 'Running' while any scope is Running/Pending and only reads 'Done' when everything is truly finished (or the final -Completed render). Add a test locking this (scope Running + items Done -> overall Running, Done count 2). 164 Pester tests passing; PSScriptAnalyzer clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Public/Export-SPSUpdateProgressReport.ps1 | 28 +++++++++++++------ .../Export-SPSUpdateProgressReport.Tests.ps1 | 12 ++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 index 11475fb..35c055d 100644 --- a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 @@ -109,14 +109,22 @@ } # ---- Overall roll-up ---------------------------------------------------------- - # Count each leaf unit of work once: the items when a scope has any (each binary, - # each database), otherwise the scope state itself (e.g. the Wizard, which carries a - # per-server state but no items). This avoids double counting a scope and its items. + # Two separate concerns: + # * Card counts: each leaf unit of work counted once - the items when a scope has any + # (each binary, each database), otherwise the scope state itself (e.g. the Wizard, + # which has a per-server state but no items). This avoids double counting. + # * Overall state: derived from BOTH scope states and item states, so a scope that is + # still Running/Pending keeps the overall "Running" even when the items recorded so + # far all happen to be Done (e.g. during the staggered sequence start). $allItemStates = New-Object System.Collections.Generic.List[string] + $overallStates = New-Object System.Collections.Generic.List[string] foreach ($sc in $scopes) { + if ($sc.State) { $overallStates.Add("$($sc.State)") } $scopeItems = @($sc.Items) if ($scopeItems.Count -gt 0) { - foreach ($it in $scopeItems) { if ($it.State) { $allItemStates.Add("$($it.State)") } } + foreach ($it in $scopeItems) { + if ($it.State) { $allItemStates.Add("$($it.State)"); $overallStates.Add("$($it.State)") } + } } elseif ($sc.State) { $allItemStates.Add("$($sc.State)") @@ -125,13 +133,15 @@ $countFailed = @($allItemStates | Where-Object { $_ -eq 'Failed' }).Count $countRunning = @($allItemStates | Where-Object { $_ -eq 'Running' }).Count $countDone = @($allItemStates | Where-Object { $_ -eq 'Done' }).Count - $countPending = @($allItemStates | Where-Object { $_ -eq 'Pending' }).Count - $countWarning = @($allItemStates | Where-Object { $_ -eq 'Warning' }).Count + + $hasFailed = @($overallStates | Where-Object { $_ -eq 'Failed' }).Count -gt 0 + $hasActive = @($overallStates | Where-Object { $_ -eq 'Running' -or $_ -eq 'Pending' }).Count -gt 0 + $hasWarning = @($overallStates | Where-Object { $_ -eq 'Warning' }).Count -gt 0 $overall = if ($scopes.Count -eq 0) { 'Pending' } - elseif ($countFailed -gt 0) { 'Failed' } - elseif ($countRunning -gt 0 -or $countPending -gt 0) { 'Running' } - elseif ($countWarning -gt 0) { 'Warning' } + elseif ($hasFailed) { 'Failed' } + elseif ($hasActive) { 'Running' } + elseif ($hasWarning) { 'Warning' } else { 'Done' } if ($Completed -and $overall -eq 'Running') { $overall = 'Done' } diff --git a/tests/Export-SPSUpdateProgressReport.Tests.ps1 b/tests/Export-SPSUpdateProgressReport.Tests.ps1 index ac71388..7844b60 100644 --- a/tests/Export-SPSUpdateProgressReport.Tests.ps1 +++ b/tests/Export-SPSUpdateProgressReport.Tests.ps1 @@ -84,6 +84,18 @@ Describe 'Export-SPSUpdateProgressReport (roll-up counts each unit once)' { $h = Get-Content -Path (Export-SPSUpdateProgressReport -CampaignPath $camp) -Raw $h | Should -Match '
2
Done
' } + + It 'stays overall Running when a scope is Running even if its items are all Done' { + # Reproduces the staggered sequence start: Sequence1 scope Running, its DBs all Done, + # but sequences 2-4 not started yet. Overall must not flip to Done prematurely. + $camp = New-Campaign -Name 'overall-running' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Running' -Percent 100 -Item 'DB_A' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -Item 'DB_B' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + $h = Get-Content -Path (Export-SPSUpdateProgressReport -CampaignPath $camp) -Raw + # Overall badge (first one, in the accent card) must be Running, and the Done count is 2. + $h | Should -Match 'card accent[^>]*>.*?' + $h | Should -Match '
2
Done
' + } } Describe 'Export-SPSUpdateProgressReport (states)' { From 34b11cc66dc01b1c163987b2b6ccf1c9237c7999 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 30 Jun 2026 14:37:03 +0200 Subject: [PATCH 12/15] polish: footer copy matches the campaign state On a completed dashboard the footer said 'refreshes itself while patching is in progress', contradicting the 'Campaign completed (auto-refresh off)' note in the header. Make the footer conditional: 'reflects the completed campaign' when done, the live wording only while running. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Public/Export-SPSUpdateProgressReport.ps1 | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 index 35c055d..f9067e5 100644 --- a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 @@ -238,13 +238,20 @@ } else { 'Campaign completed (auto-refresh off).' } + $footerNote = if ($effectiveRefresh -gt 0) { + 'It is assembled from the shared status store and refreshes itself while patching is in progress.' + } + else { + 'It is assembled from the shared status store and reflects the completed campaign.' + } + # ---- Assemble ----------------------------------------------------------------- $html = (Get-SPSReportHtmlHead -Title $encTitle -RefreshSeconds $effectiveRefresh) + "

$encTitle

" + "
$metaLine · $liveNote
" + "

Overall

$cards
" + $sections + - "
Generated by SPSUpdate $encVersion. This dashboard is assembled from the shared status store and refreshes itself while patching is in progress.
" + + "
Generated by SPSUpdate $encVersion. $footerNote
" + '' $outDir = Split-Path -Path $OutputFile -Parent From 409d1657e7dc55cfb8c109fcd2f72330ce597ea0 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 30 Jun 2026 16:49:33 +0200 Subject: [PATCH 13/15] feat: collapse finished scopes on the dashboard (progressive disclosure) Operators had to scroll past every completed block to find what still needs attention. Render each scope as a native
/: finished scopes (Done/Skipped) are collapsed by default, active ones (Running/Pending/Failed/ Warning) stay expanded. The always-visible summary line keeps the badge, the percentage and a compact 'N/M done' count, and a CSS chevron rotates on expand. No JavaScript - works offline over file:// and is keyboard/screen-reader accessible. On the live (auto-refreshing) dashboard a scope re-collapses once it turns Done, so the view stays focused on in-flight work. 164 Pester tests passing; PSScriptAnalyzer clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ .../Private/Get-SPSReportHtmlHead.ps1 | 9 ++++++++- .../Public/Export-SPSUpdateProgressReport.ps1 | 20 ++++++++++++++++--- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ca1c1..6b91ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documented that the InstallAccount needs **Modify** on the status store share (SMB + NTFS) for the upgrade phase to appear on the dashboard (Getting-Started, Configuration, Usage and the offline guide). +- The dashboard now collapses finished scopes (Done/Skipped) by default and keeps active ones + (Running/Pending/Failed/Warning) expanded, using native `
`/`` (progressive + disclosure, accessible, no JS). Each collapsed scope still shows its badge, percentage and a + compact "N/M done" count on the summary line. ### Fixed diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 index 102738b..d049921 100644 --- a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 @@ -56,8 +56,15 @@ tbody tr:nth-child(even){background:var(--zebra)} .badge.Skipped{background:#cfd6dc;color:#333} .phase{margin-top:18px} .scope{border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin:8px 0;background:#fff} +details.scope>summary{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--brand-dark);cursor:pointer;list-style:none;outline:none} +details.scope>summary::-webkit-details-marker{display:none} +details.scope>summary::before{content:'';flex:0 0 auto;width:0;height:0;border-left:5px solid var(--muted);border-top:4px solid transparent;border-bottom:4px solid transparent;transition:transform .15s ease;margin-right:2px} +details.scope[open]>summary::before{transform:rotate(90deg)} +details.scope>summary:hover{color:var(--brand)} +details.scope>summary:focus-visible{box-shadow:0 0 0 2px #cfe0ef;border-radius:4px} .scope-head{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--brand-dark)} -.scope-detail{color:var(--muted);font-size:11px;margin-top:2px} +.scope-detail{color:var(--muted);font-size:11px;margin-top:6px} +.count{color:var(--muted);font-weight:600} .items{margin-top:6px} .live{color:#2e9b57;font-size:11px} .footer{color:var(--muted);font-size:11px;margin-top:24px;border-top:1px solid var(--line);padding-top:8px} diff --git a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 index f9067e5..be2868a 100644 --- a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 @@ -188,7 +188,22 @@ try { $updated = " · updated $([datetime]::Parse($sc.UpdatedAt).ToString('HH:mm:ss'))" } catch { $updated = '' } } - $sections += "
$(& $badge $sc.State) $encServer / $encScope$pctText
" + $items = @($sc.Items) + + # Compact item summary shown on the (always visible) summary line, e.g. "3/4 done". + $itemSummary = '' + if ($items.Count -gt 0) { + $itemsDone = @($items | Where-Object { "$($_.State)" -eq 'Done' }).Count + $itemSummary = " · $itemsDone/$($items.Count) done" + } + + # Progressive disclosure: collapse finished scopes (Done/Skipped) by default so the + # operator sees what still needs attention without scrolling; keep active ones open. + $scopeState = "$($sc.State)" + $isFinished = ($scopeState -eq 'Done' -or $scopeState -eq 'Skipped') + $openAttr = if ($isFinished) { '' } else { ' open' } + + $sections += "
$(& $badge $sc.State) $encServer / $encScope$pctText$itemSummary" if (-not [string]::IsNullOrEmpty($encDetail)) { $sections += "
$encDetail$updated
" } @@ -196,7 +211,6 @@ $sections += "
$($updated.TrimStart(' ·'))
" } - $items = @($sc.Items) if ($items.Count -gt 0) { $rows = '' foreach ($it in $items) { @@ -207,7 +221,7 @@ } $sections += "
$rows
ItemStateDetailExit
" } - $sections += '
' + $sections += '
' } $sections += '' } From bf7244711b96bce3299039e93177ce7926f0a3a7 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 30 Jun 2026 16:57:25 +0200 Subject: [PATCH 14/15] feat: surface real process exit codes on the dashboard The Exit column and the wizard status were never populated because the instrumentation discarded the process exit codes. Wire them through: - Start-SPSProductUpdate now returns the setup.exe exit code (or $null when no install was needed) and no longer leaks the iisreset -start Process object into its output. SPSUpdate.ps1 records it on the ProductUpdate item with a friendly detail (0 -> installed, 17022 -> reboot required, 17025 -> already installed) and the numeric code in the Exit column. - The Configuration Wizard captures the psconfig.exe exit code (local via Start-SPSConfigExe, remote via Start-SPSConfigExeRemote) -- robustly filtering the integer return from the functions' Write-Output lines -- and records it in the scope detail, e.g. 'PSConfig completed (exit 0)'. Content-database upgrade items have no process exit code (cmdlet) so their Exit column stays blank by design. PSScriptAnalyzer clean; 164 Pester tests passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 ++++ .../Public/Start-SPSProductUpdate.ps1 | 5 +++- src/SPSUpdate.ps1 | 28 +++++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b91ab8..9d4dd68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,11 +44,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (Running/Pending/Failed/Warning) expanded, using native `
`/`` (progressive disclosure, accessible, no JS). Each collapsed scope still shows its badge, percentage and a compact "N/M done" count on the summary line. +- The real process exit codes now flow to the dashboard: `Start-SPSProductUpdate` returns the + setup.exe exit code (shown in the ProductUpdate item's Exit column, e.g. `0`, `17022` = reboot + required), and the Configuration Wizard records the psconfig.exe exit code in its detail + (`PSConfig completed (exit 0)`), for the local and remote servers. ### Fixed - The dashboard roll-up counted each unit of work once instead of summing both a scope and its items (e.g. two servers each installing one binary now read "2 Done", not "4"). +- `Start-SPSProductUpdate` no longer leaks the `iisreset -start` Process object into its output + (it is piped to `Out-Null`), so its return value is a clean exit code. ### Tests diff --git a/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 b/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 index fb08b19..9c926c1 100644 --- a/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 @@ -1,5 +1,6 @@ function Start-SPSProductUpdate { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + [OutputType([System.Nullable[int]])] param ( [Parameter(Mandatory = $true)] @@ -136,12 +137,14 @@ Error codes can be found at https://aka.ms/installerrorcodes Start-Process -FilePath "iisreset.exe" ` -ArgumentList "-start" ` -Wait ` - -PassThru + -PassThru | Out-Null write-Verbose -Message "All services started. Installation process complete." } + return [int]$setupInstall.ExitCode } else { # Version of SharePoint is equal or greater than the patch version. Patch is installed. Write-Verbose -Message "The version of SharePoint installed is equal or higher than the update. No action needed." + return $null } } diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index d517d61..6378035 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -654,8 +654,20 @@ Shutdown Services: $($envCfg.Binaries.ShutdownServices) # ProductUpdate even when the system was actually in a healthy state. # Unblock setup file if it is blocked Unblock-File -Path $fullSetupFilePath -Verbose - Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $envCfg.Binaries.ShutdownServices -Verbose - Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Item $setupFile -ItemState 'Done' -ItemDetail 'installed' + $puExitCode = Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $envCfg.Binaries.ShutdownServices -Verbose + if ($null -eq $puExitCode) { + # No install happened: already at or above the patch level. + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Item $setupFile -ItemState 'Done' -ItemDetail 'already installed' + } + else { + $puDetail = switch ([int]$puExitCode) { + 0 { 'installed' } + 17022 { 'installed - reboot required' } + 17025 { 'already installed' } + default { "installed (exit $puExitCode)" } + } + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Item $setupFile -ItemState 'Done' -ItemDetail $puDetail -ExitCode ([int]$puExitCode) + } Write-SPSDashboard } Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -State 'Done' -Detail 'All updates processed' @@ -897,8 +909,10 @@ Exception: $_ Write-Output "Action Required on server: $($env:COMPUTERNAME). Proceeding to run Configuration Wizard." Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Running' -Detail 'Running PSConfig' Write-SPSDashboard - Start-SPSConfigExe - Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Done' -Detail 'PSConfig completed' + $wizResult = Start-SPSConfigExe + $wizExit = @($wizResult) | Where-Object { $_ -is [int] } | Select-Object -Last 1 + $wizDetail = if ($null -ne $wizExit) { "PSConfig completed (exit $([int]$wizExit))" } else { 'PSConfig completed' } + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Done' -Detail $wizDetail } Write-SPSDashboard } @@ -931,8 +945,10 @@ Exception: $_ $spTargetServer = "$($spServer.Name).$($scriptFQDN)" Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Running' -Detail 'Running PSConfig (remote)' Write-SPSDashboard - Start-SPSConfigExeRemote -Server $spTargetServer -InstallAccount $credential - Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Done' -Detail 'PSConfig completed' + $wizResultRemote = Start-SPSConfigExeRemote -Server $spTargetServer -InstallAccount $credential + $wizExitRemote = @($wizResultRemote) | Where-Object { $_ -is [int] } | Select-Object -Last 1 + $wizDetailRemote = if ($null -ne $wizExitRemote) { "PSConfig completed (exit $([int]$wizExitRemote))" } else { 'PSConfig completed' } + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Done' -Detail $wizDetailRemote } Write-SPSDashboard } From 11fcbc2903f498d8da186fbd3b226ed3cd510329 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 30 Jun 2026 17:13:40 +0200 Subject: [PATCH 15/15] docs: finalize 4.2.0 release notes (date + live-test polish) Set the [4.2.0] date to 2026-06-30 and expand RELEASE-NOTES with the improvements that came out of the real-farm live test: the readiness service-account write probe, the collapsible finished scopes, the surfaced process exit codes, the required share permission, and a note that the release was validated end-to-end on a real Subscription Edition farm with an actual cumulative update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- RELEASE-NOTES.md | 31 ++++++++++++++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d4dd68..78ee1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ 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.2.0] - 2026-06-29 +## [4.2.0] - 2026-06-30 ### Added diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 81b9923..ac7dbbc 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,6 +1,6 @@ # SPSUpdate - Release Notes -## [4.2.0] - 2026-06-29 +## [4.2.0] - 2026-06-30 This release adds a near real-time patching dashboard: while a cumulative update is rolled out across the farm, SPSUpdate records the progress of every phase into a shared status @@ -22,18 +22,35 @@ store and the master assembles a self-contained, auto-refreshing HTML dashboard. regenerates the dashboard on every wait-loop iteration and writes a final completed dashboard with auto-refresh off. - New `Test-SPSUpdateReadiness.ps1` pre-flight check (module, config, DPAPI secret, - elevation, status store write access, per-server CredSSP reachability). + elevation, status store write access, per-server CredSSP reachability). The status store + check probes write access **both as the current user and as the InstallAccount**, since the + upgrade sequences run as the service account and only appear on the dashboard when it can + write to the share. +- The dashboard collapses finished scopes (Done/Skipped) by default and keeps active ones + expanded (native `
`/``, accessible, no JS); each collapsed line still + shows its badge, percentage and an `N/M done` count. +- Real process exit codes are surfaced: the ProductUpdate item's Exit column shows the + setup.exe code (`0`, `17022` = reboot required, ...), and the Configuration Wizard records + the psconfig.exe code in its detail (`PSConfig completed (exit 0)`). ### How to use -1. Set `StatusStorePath` to a UNC share writable by the InstallAccount from every server. -2. `SPSUpdate.ps1 -ConfigFile '.psd1' -Action ResetStatus` to start a clean campaign. -3. Open `\--\_dashboard.html` in a browser. -4. Run `-Action ProductUpdate` on each server, then the default master run; watch the +1. Set `StatusStorePath` to a UNC share writable by the InstallAccount from every server + (grant it **Modify** on the SMB share and NTFS — the sequence tasks run as that account). +2. Run `Test-SPSUpdateReadiness.ps1` to confirm the environment (both write probes green). +3. `SPSUpdate.ps1 -ConfigFile '.psd1' -Action ResetStatus` to start a clean campaign. +4. Open `\--\_dashboard.html` in a browser. +5. Run `-Action ProductUpdate` on each server, then the default master run; watch the dashboard update itself. ### Changed -- Bumped the module manifest to `4.2.0` and exported the four new functions. +- Bumped the module manifest to `4.2.0` and exported the new functions. + +### Notes + +- Validated end-to-end on a real three-server Subscription Edition farm with an actual + cumulative update (binary install, parallel content-database upgrade, and the post-setup + Configuration Wizard, all reflected live on the dashboard). A full list of changes in each version can be found in the [change log](CHANGELOG.md)