Public/Get-SPMigrationReport.ps1
|
function Get-SPMigrationReport { <# .SYNOPSIS Produit un rapport exploitable à partir de l'état et des journaux NDJSON. .DESCRIPTION Le rapport est calculé à partir du magasin d'état structuré, jamais par correspondance de chaînes dans un fichier de log — c'est précisément ce que faisait la version précédente, avec des compteurs faux dès qu'un libellé changeait. Deux sorties : un objet PowerShell (chaînable, testable) et, si -HtmlPath est fourni, une page HTML autonome à transmettre au client. .PARAMETER StatePath Fichier d'état NDJSON produit par Invoke-SPMigration. .PARAMETER LogPath Journal NDJSON complémentaire (optionnel) pour les statistiques de throttling. .PARAMETER HtmlPath Chemin d'écriture du rapport HTML. .EXAMPLE Get-SPMigrationReport -StatePath .\Logs\prod.state.ndjson -HtmlPath .\Logs\rapport.html #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory, Position = 0)][string]$StatePath, [string]$LogPath, [string]$HtmlPath ) if (-not (Test-Path -LiteralPath $StatePath)) { throw "Fichier d'état introuvable : $StatePath" } # Dernier événement par clé : l'état final de chaque unité de travail $latest = [ordered]@{} foreach ($line in [System.IO.File]::ReadLines($StatePath)) { if ([string]::IsNullOrWhiteSpace($line)) { continue } try { $record = $line | ConvertFrom-Json $latest[$record.key] = $record } catch { continue } } $units = @($latest.Values) $byStatus = $units | Group-Object status $failures = @($units | Where-Object status -eq 'Failed' | ForEach-Object { [pscustomobject]@{ Source = $_.sourceUrl Target = $_.targetUrl Error = $_.PSObject.Properties['error'] ? $_.error : $null Time = $_.ts } }) $durations = @($units | Where-Object { $_.status -eq 'Completed' -and $_.PSObject.Properties['durationMs'] } | ForEach-Object { [double]$_.durationMs }) $throttle = $null if ($LogPath -and (Test-Path -LiteralPath $LogPath)) { $throttleEvents = 0 $throttleWaitMs = 0 foreach ($line in [System.IO.File]::ReadLines($LogPath)) { if ([string]::IsNullOrWhiteSpace($line)) { continue } try { $e = $line | ConvertFrom-Json if ($e.PSObject.Properties['throttled'] -and $e.throttled) { $throttleEvents++ if ($e.PSObject.Properties['delayMs']) { $throttleWaitMs += [int]$e.delayMs } } } catch { continue } } $throttle = [pscustomobject]@{ Events = $throttleEvents WaitedSec = [math]::Round($throttleWaitMs / 1000, 1) } } $completed = @($units | Where-Object status -eq 'Completed').Count $running = @($units | Where-Object status -eq 'Running').Count $submitted = @($units | Where-Object status -eq 'Submitted').Count # Un rapport ne doit jamais présenter une absence de mesure comme une mesure. # « Durée moyenne : 0 s » se lisait comme « instantané » alors que rien # n'avait été chronométré, et une carte verte sur zéro élément transféré # laissait conclure à une migration réussie. $alertes = [System.Collections.Generic.List[string]]::new() if ($units.Count -eq 0) { $alertes.Add("Aucune unité dans le fichier d'état : ce rapport ne mesure rien.") } elseif ($completed -eq 0) { $alertes.Add("RIEN N'A ÉTÉ TRANSFÉRÉ : aucune unité terminée sur $($units.Count).") } if ($running) { $alertes.Add("$running unité(s) restée(s) en cours : exécution interrompue, résultat incomplet.") } if ($submitted) { $alertes.Add( "$submitted travail(aux) SOUMIS et non confirmés : le service les a acceptés, " + "rien ne prouve qu'il les ait exécutés." ) } if (-not $durations.Count -and $completed) { $alertes.Add('Aucune durée mesurée : les temps affichés sont « non mesuré », pas zéro.') } $report = [pscustomobject]@{ GeneratedAt = (Get-Date).ToString('o') StatePath = (Resolve-Path $StatePath).Path TotalUnits = $units.Count Completed = $completed Failed = $failures.Count Skipped = @($units | Where-Object status -eq 'Skipped').Count Running = $running Submitted = $submitted SuccessRate = if ($units.Count) { [math]::Round(($completed / $units.Count) * 100, 1) } else { $null } MeasuredUnits = $durations.Count AvgDurationMs = if ($durations.Count) { [math]::Round(($durations | Measure-Object -Average).Average, 0) } else { $null } MaxDurationMs = if ($durations.Count) { [math]::Round(($durations | Measure-Object -Maximum).Maximum, 0) } else { $null } NothingTransferred = ($completed -eq 0) Alerts = $alertes.ToArray() Throttling = $throttle StatusBreak = @($byStatus | ForEach-Object { [pscustomobject]@{ Status = $_.Name; Count = $_.Count } }) Failures = $failures } foreach ($a in $alertes) { Write-SPMLog -Level WARNING -Operation 'Report' -Message $a } if ($HtmlPath) { $dir = Split-Path -Parent $HtmlPath if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } Write-SPMHtmlReport -Report $report -Path $HtmlPath Write-SPMLog -Level SUCCESS -Operation 'Report' -Message "Rapport HTML écrit : $HtmlPath" } return $report } |