Copiez et collez cet exemple de script et modifiez-le si nécessaire pour votre environnement :

<# . SYNOPSIS     Agrège le démarrage sécurisé status données JSON de plusieurs appareils dans des rapports de synthèse.

. DESCRIPTION     Lit le démarrage sécurisé collecté status fichiers JSON et génère :     - Tableau de bord HTML avec graphiques et filtrage     - Résumé par ConfidenceLevel     - Analyse de compartiment d’appareils unique pour la stratégie de test     Soutient:     - Fichiers par ordinateur : HOSTNAME_latest.json (recommandé)     - Fichier JSON unique     Déduplique automatiquement par HostName, en conservant collectionTime le plus récent.               Par défaut, inclut uniquement les appareils avec une confiance « Action Req » ou « Haute »     pour se concentrer sur les compartiments actionnables. Utilisez -IncludeAllConfidenceLevels pour remplacer.

. PARAMETER InputPath     Chemin d’accès au ou aux fichiers JSON :     - Dossier : lit tous les fichiers *_latest.json (ou *.json si aucun fichier _latest)     - Fichier : lit un seul fichier JSON

. PARAMETER OutputPath     Chemin des rapports générés (par défaut : .\SecureBootReports)

. EXEMPLE     # Agréger à partir d’un dossier de fichiers par ordinateur (recommandé)     .\Aggregate-SecureBootData.ps1 -InputPath « \\contoso\SecureBootLogs$ »     Nombre de lectures : \\contoso\SecureBootLogs$\*_latest.json

. EXEMPLE     # Emplacement de sortie personnalisé     .\Aggregate-SecureBootData.ps1 -InputPath « \\contoso\SecureBootLogs$ » -OutputPath « C :\Reports\SecureBoot »

. EXEMPLE     # Inclure uniquement Action Req et Haute confiance (comportement par défaut)     .\Aggregate-SecureBootData.ps1 -InputPath « \\contoso\SecureBootLogs$ »     # Exclut : Observation, Suspendu, Non pris en charge

. EXEMPLE     # Inclure tous les niveaux de confiance (filtre de remplacement)     .\Aggregate-SecureBootData.ps1 -InputPath « \\contoso\SecureBootLogs$ » -IncludeAllConfidenceLevels

. EXEMPLE     # Filtre de niveau de confiance personnalisé     .\Aggregate-SecureBootData.ps1 -InputPath « \\contoso\SecureBootLogs$ » -IncludeConfidenceLevels @(« Action Req », « High », « Observation »)

. EXEMPLE     # ENTERPRISE SCALE : mode incrémentiel : traiter uniquement les fichiers modifiés (exécutions suivantes rapides)     .\Aggregate-SecureBootData.ps1 -InputPath « \\contoso\SecureBootLogs$ » -IncrementalMode     # Première exécution : Chargement complet ~ 2 heures pour 500 000 appareils     Nombre d’exécutions suivantes : secondes si aucune modification, minutes pour les deltas

. EXEMPLE     # Ignorer le code HTML si rien n’a changé (plus rapide pour la surveillance)     .\Aggregate-SecureBootData.ps1 -InputPath « \\contoso\SecureBootLogs$ » -IncrementalMode -SkipReportIfUnchanged     # Si aucun fichier n’a été modifié depuis la dernière exécution : ~5 secondes

. EXEMPLE     Mode # Résumé uniquement : ignorer les tables d’appareils volumineuses (1 à 2 minutes ou plus de 20 minutes)     .\Aggregate-SecureBootData.ps1 -InputPath « \\contoso\SecureBootLogs$ » -SummaryOnly     # Génère des csv, mais ignore le tableau de bord HTML avec des tables d’appareils complètes

. NOTES     Couplez avec Detect-SecureBootCertUpdateStatus.ps1 pour le déploiement d’entreprise.Consultez GPO-DEPLOYMENT-GUIDE.md pour le guide de déploiement complet.     Le comportement par défaut exclut les appareils Observation, Suspendus et Non pris en charge     pour concentrer la création de rapports sur les compartiments d’appareils actionnables uniquement.#>

param(     [Parameter(Mandatory = $true)]     [string]$InputPath,          [Parameter(Mandatory = $false)]     [string]$OutputPath = .\SecureBootReports",          [Parameter(Mandatory = $false)]     [string]$ScanHistoryPath = .\SecureBootReports\ScanHistory.json",          [Parameter(Mandatory = $false)]     [string]$RolloutStatePath, # Chemin d’accès à RolloutState.json pour identifier les appareils      InProgress     [Parameter(Mandatory = $false)]     [string]$RolloutSummaryPath, # Chemin d’accès à SecureBootRolloutSummary.json à partir d’Orchestrator (contient des données de projection)          [Parameter(Mandatory = $false)]     [string[]]$IncludeConfidenceLevels = @(« Action required », « High Confidence »), # incluez uniquement ces niveaux de confiance (par défaut : compartiments actionnables uniquement)          [Parameter(Mandatory = $false)]     [switch]$IncludeAllConfidenceLevels, # Remplacer le filtre pour inclure tous les niveaux      de confiance     [Parameter(Mandatory = $false)]     [switch]$SkipHistoryTracking,          [Parameter(Mandatory = $false)]     [switch]$IncrementalMode, # Activer le traitement delta : charger uniquement les fichiers modifiés depuis la dernière exécution          [Parameter(Mandatory = $false)]     [string]$CachePath, # Chemin d’accès au répertoire du cache (par défaut : OutputPath\.cache)          [Parameter(Mandatory = $false)]     [int]$ParallelThreads = 8, # Nombre de threads parallèles pour le chargement de fichiers (PS7+)          [Parameter(Mandatory = $false)]     [switch]$ForceFullRefresh, # Forcer le rechargement complet, même en mode      incrémentiel     [Parameter(Mandatory = $false)]     [switch]$SkipReportIfUnchanged, # Ignorer la génération HTML/CSV si aucun fichier n’a été modifié (uniquement les statistiques de sortie)          [Parameter(Mandatory = $false)]     [switch]$SummaryOnly, # Générer des statistiques récapitulatives uniquement (pas de tables d’appareils volumineuses) - beaucoup plus rapidement          [Parameter(Mandatory = $false)]     [switch]$StreamingMode # Mode mémoire efficace : traiter les segments, écrire des csv de manière incrémentielle, conserver uniquement les résumés en mémoire )

# Élévation automatique vers PowerShell 7 si disponible (6 fois plus rapide pour les jeux de données volumineux) if ($PSVersionTable.PSVersion.Major -lt 7) {     $pwshPath = Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source     if ($pwshPath) {         Write-Host « PowerShell $($PSVersionTable.PSVersion) détecté - re-lancement avec PowerShell 7 pour un traitement plus rapide... - ForegroundColor Yellow         # Reconstruire la liste d’arguments à partir de paramètres liés         $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path)         foreach ($key dans $PSBoundParameters.Keys) {             $val = $PSBoundParameters[$key]             if ($val -is [switch]) {                 if ($val. IsPresent) { $relaunchArgs += « -$key » }             } elseif ($val -is [array]) {                 $relaunchArgs += « -$key »                 $relaunchArgs += ($val -join ',')             } else {                 $relaunchArgs += « -$key »                 $relaunchArgs += « $val »             }         }         & $pwshPath @relaunchArgs         $LASTEXITCODE de sortie     } }

$ErrorActionPreference = « Continue » $timestamp = Get-Date -Format « aaaaMMdd-HHmmss » $scanTime = Get-Date -Format « aaaa-MM-dd HH :mm :ss » $DownloadUrl = « https://aka.ms/getsecureboot » $DownloadSubPage = « Exemples de déploiement et de surveillance »

# Remarque : Ce script n’a aucune dépendance sur les autres scripts. # Pour l’ensemble d’outils complet, téléchargez à partir de : $DownloadUrl -> $DownloadSubPage

Configuration de #region Write-Host « = » * 60 -ForegroundColor Cyan Write-Host « Secure Boot Data Aggregation » -ForegroundColor Cyan Write-Host « = » * 60 -ForegroundColor Cyan

# Créer un répertoire de sortie if (-not (Test-Path $OutputPath)) {     New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null }

# Charger des données : prend en charge les formats CSV (hérité) et JSON (natif) Write-Host « 'nLoading data from : $InputPath » -ForegroundColor Yellow

Fonction d’assistance # pour normaliser l’objet d’appareil (gérer les différences de nom de champ) function Normalize-DeviceRecord {     param($device)     # Gérer le nom d’hôte et le nom d’hôte (JSON utilise le nom d’hôte, CSV utilise le nom d’hôte)     if ($device. PSObject.Properties['Hostname'] -and -not $device. PSObject.Properties['HostName']) {         $device | Add-Member -NotePropertyName 'HostName' -NotePropertyValue $device. Nom d’hôte -Force     }     # Handle Confidence vs ConfidenceLevel (JSON utilise Confidence, CSV utilise ConfidenceLevel)     # ConfidenceLevel est le nom officiel du champ - mapper confiance à celui-ci     if ($device. PSObject.Properties['Confidence'] -and -not $device. PSObject.Properties['ConfidenceLevel']) {         $device | Add-Member -NotePropertyName 'ConfidenceLevel' -NotePropertyValue $device. Confiance -Force     }     # Suivre les status de mise à jour via Event1808Count OU UEFICA2023Status="Updated »     # Cela permet de suivre le nombre d’appareils dans chaque compartiment de confiance qui ont été mis à jour     $event 1808 = 0     if ($device. PSObject.Properties['Event1808Count']) {         $event 1808 = [int]$device. Event1808Count     }     $uefiCaUpdated = $false     if ($device. PSObject.Properties['UEFICA2023Status'] - et $device. UEFICA2023Status -eq « Updated ») {         $uefiCaUpdated = $true     }     if ($event 1808 -gt 0 -or $uefiCaUpdated) {         # Marquer comme mis à jour pour la logique de tableau de bord/de déploiement, mais NE PAS remplacer ConfidenceLevel         $device | Add-Member -NotePropertyName 'IsUpdated' -NotePropertyValue $true -Force     } else {         $device | Add-Member -NotePropertyName 'IsUpdated' -NotePropertyValue $false -Force         Classification # ConfidenceLevel :         # - « Haute confiance », « En observation... », « Temporairement suspendu... », « Non pris en charge... » = utiliser tel tel qu’il est         # - Tout le reste (null, empty, « UpdateType :... », « Unknown », « N/A ») = tombe à Action Requise dans les compteurs         # Aucune normalisation n’est nécessaire : la branche Else du compteur de streaming le gère     }     # Gérer OEMManufacturerName vs WMI_Manufacturer (JSON utilise OEM*, hérité utilise WMI_*)     if ($device. PSObject.Properties['OEMManufacturerName'] -and -not $device. PSObject.Properties['WMI_Manufacturer']) {         $device | Add-Member -NotePropertyName 'WMI_Manufacturer' -NotePropertyValue $device. OEMManufacturerName -Force     }     # Gérer OEMModelNumber par rapport à WMI_Model     if ($device. PSObject.Properties['OEMModelNumber'] -and -not $device. PSObject.Properties['WMI_Model']) {         $device | Add-Member -NotePropertyName 'WMI_Model' -NotePropertyValue $device. OEMModelNumber -Force     }     # Gérer FirmwareVersion vs BIOSDescription     if ($device. PSObject.Properties['FirmwareVersion'] -and -not $device. PSObject.Properties['BIOSDescription']) {         $device | Add-Member -NotePropertyName 'BIOSDescription' -NotePropertyValue $device. FirmwareVersion -Force     }     $device de retour }                                                 

#region traitement incrémentiel/gestion du cache Nombre de chemins d’installation du cache if (-not $CachePath) {     $CachePath = Join-Path $OutputPath .cache" } $manifestPath = Join-Path $CachePath « FileManifest.json » $deviceCachePath = Join-Path $CachePath « DeviceCache.json »

Nombre de fonctions de gestion du cache function Get-FileManifest {     param([string]$Path)     if (Test-Path $Path) {         try {             $json = Get-Content $Path -Raw | ConvertFrom-Json             # Convertir PSObject en table de hachage (compatible PS5.1 - PS7 a -AsHashtable)             $ht = @{}             $json. PSObject.Properties | ForEach-Object { $ht[$_. Name] = $_. Valeur }             $ht de retour         } catch {             return @{}         }     }     return @{} }

function Save-FileManifest {     param([hashtable]$Manifest, [string]$Path)     $dir = Split-Path $Path -Parent     if (-not (Test-Path $dir)) {         New-Item -ItemType Directory -Path $dir -Force | Out-Null     }     $Manifest | ConvertTo-Json -Profondeur 3 -Compresser | Set-Content $Path -Force }

function Get-DeviceCache {     param([string]$Path)     if (Test-Path $Path) {         try {             $cacheData = Get-Content $Path -Raw | ConvertFrom-Json             Write-Host « Loaded device cache : $($cacheData.Count) devices » -ForegroundColor DarkGray             $cacheData de retour         } catch {             Write-Host « Cache endommagé, régénéré » -ForegroundColor Yellow             return @()         }     }     return @() }

function Save-DeviceCache {     param($Devices, [string]$Path)     $dir = Split-Path $Path -Parent     if (-not (Test-Path $dir)) {         New-Item -ItemType Directory -Path $dir -Force | Out-Null     }     # Convertir en tableau et enregistrer     $deviceArray = @($Devices)     $deviceArray | ConvertTo-Json -Profondeur 10 -Compresser | Set-Content $Path -Force     Write-Host « Cache d’appareil enregistré : appareils $($deviceArray.Count) » -ForegroundColor DarkGray }

function Get-ChangedFiles {     param(         [System.IO.FileInfo[]]$AllFiles,         [table de hachage]$Manifest     )          $changed = [System.Collections.ArrayList] ::new()     $unchanged = [System.Collections.ArrayList] ::new()     $newManifest = @{}          # Générer une recherche non sensible à la casse à partir du manifeste (normaliser en minuscules)     $manifestLookup = @{}     foreach ($mk dans $Manifest.Keys) {         $manifestLookup[$mk. ToLowerInvariant()] = $Manifest[$mk]     }          foreach ($file dans $AllFiles) {         $key = $file. FullName.ToLowerInvariant() # Normalize path to lowercase         $lwt = $file. LastWriteTimeUtc.ToString(« o »)         $newManifest[$key] = @{             LastWriteTimeUtc = $lwt             Taille = $file. Longueur         }                  if ($manifestLookup.ContainsKey($key)) {             $cached = $manifestLookup[$key]             if ($cached. LastWriteTimeUtc -eq $lwt -and $cached. Taille -eq $file. Longueur) {                 [void]$unchanged. Add($file)                 Continuer             }         }         [void]$changed. Add($file)     }          return @{         Modifié = $changed         Inchangé = $unchanged         NewManifest = $newManifest     } }

Chargement de fichiers parallèles ultra-rapides à l’aide d’un traitement par lots function Load-FilesParallel {     param(         [System.IO.FileInfo[]]$Files,         [int]$Threads = 8     )

$totalFiles = $Files. Compter     # Utiliser des lots d’environ 1 000 fichiers chacun pour un meilleur contrôle de la mémoire     $batchSize = [math] ::Min(1000, [math] ::Ceiling($totalFiles / [math] ::Max(1, $Threads)))     $batches = [System.Collections.Generic.List[object]] ::new()     

for ($i = 0 ; $i -lt $totalFiles ; $i += $batchSize) {         $end = [math] ::Min($i + $batchSize, $totalFiles)         $batch = $Files[$i.. ($end-1)]         $batches. Add($batch)     }     Write-Host " ($($batches. Count) lots de ~$batchSize fichiers chacun)" -NoNewline -ForegroundColor DarkGray     $flatResults = [System.Collections.Generic.List[object]] ::new()     # Vérifier si PowerShell 7+ parallel est disponible     $canParallel = $PSVersionTable.PSVersion.Major -ge 7     if ($canParallel -and $Threads -gt 1) {         # PS7+ : Traiter les lots en parallèle         $results = $batches | ForEach-Object -ThrottleLimit $Threads -Parallel {             $batchFiles = $_             $batchResults = [System.Collections.Generic.List[object]] ::new()             foreach ($file dans $batchFiles) {                 try {                     $content = [System.IO.File] ::ReadAllText($file. FullName) | ConvertFrom-Json                     $batchResults.Add($content)                 } catch { }             }             $batchResults.ToArray()         }         foreach ($batch dans $results) {             if ($batch) { foreach ($item in $batch) { $flatResults.Add($item) } }         }     } else {         # PS5.1 de secours : traitement séquentiel (toujours rapide pour <10 000 fichiers)         foreach ($file en $Files) {             try {                 $content = [System.IO.File] ::ReadAllText($file. FullName) | ConvertFrom-Json                 $flatResults.Add($content)             } catch { }         }     }     return $flatResults.ToArray() } #endregion                         

$allDevices = @() if (Test-Path $InputPath -PathType Leaf) {     # Fichier JSON unique     if ($InputPath -like « *.json ») {         $jsonContent = Get-Content -Chemin $InputPath -Raw | ConvertFrom-Json         $allDevices = @($jsonContent) | ForEach-Object { Normalize-DeviceRecord $_ }         Write-Host « Enregistrements $($allDevices.Count) chargés à partir du fichier »     } else {         Write-Error « Seul le format JSON est pris en charge. Le fichier doit avoir .json extension. »         sortie 1     } } elseif (Test-Path $InputPath -PathType Container) {     # Dossier - JSON uniquement     $jsonFiles = @(Get-ChildItem -Path $InputPath -Filter « *.json » -Recurse -ErrorAction SilentlyContinue |                    Where-Object { $_. Name -notmatch "ScanHistory|RolloutState|RolloutPlan" })          # Préférer *_latest.json fichiers s’ils existent (mode par ordinateur)     $latestJson = $jsonFiles | Where-Object { $_. Nom -like « *_latest.json » }     if ($latestJson.Count -gt 0) { $jsonFiles = $latestJson }          $totalFiles = $jsonFiles.Count          if ($totalFiles -eq 0) {         Write-Error « Aucun fichier JSON trouvé dans : $InputPath »         sortie 1     }          Write-Host « Fichiers JSON trouvés $totalFiles » -ForegroundColor Gray          Fonction d’assistance # pour faire correspondre les niveaux de confiance (gère les formulaires courts et complets)     # Défini à l’avance pour que streamingMode et les chemins d’accès normaux puissent l’utiliser     function Test-ConfidenceLevel {         param([string]$Value, [string]$Match)         if ([string] ::IsNullOrEmpty($Value)) { return $false }         switch ($Match) {             « HighConfidence » { return $Value -eq « High Confidence » }             « UnderObservation » { return $Value -like « Under Observation* » }             « ActionRequired » { return ($Value -like « *Action Required* » -or $Value -eq « Action Required ») }             « TemporarilyPaused » { return $Value -like « Temporairement suspendu* » }             « NotSupported » { return ($Value -like « Not Supported* » -or $Value -eq « Not Supported ») }             default { return $false }         }     }          mode de diffusion en continu #region - Traitement à l’efficacité de la mémoire pour les jeux de données volumineux     # Toujours utiliser StreamingMode pour un traitement efficace en mémoire et un tableau de bord nouveau style     if (-not $StreamingMode) {         Write-Host « Activation automatique de StreamingMode (nouveau tableau de bord) » -ForegroundColor Yellow         $StreamingMode = $true         if (-not $IncrementalMode) { $IncrementalMode = $true }     }          # Lorsque -StreamingMode est activé, traitez les fichiers en blocs en conservant uniquement les compteurs en mémoire.# Les données au niveau de l’appareil sont écrites dans des fichiers JSON par bloc pour le chargement à la demande dans le tableau de bord.Utilisation de la mémoire : ~1,5 Go, quelle que soit la taille du jeu de données (contre 10 à 20 Go sans diffusion en continu).if ($StreamingMode) {         Write-Host « MODE STREAMING activé - traitement à l’efficacité de la mémoire » -ForegroundColor Vert         $streamSw = [System.Diagnostics.Stopwatch] ::StartNew()         # VÉRIFICATION INCRÉMENTIELLE : si aucun fichier n’a été modifié depuis la dernière exécution, ignorez entièrement le traitement         if ($IncrementalMode -and -not $ForceFullRefresh) {             $stManifestDir = Join-Path $OutputPath .cache "             $stManifestPath = Join-Path $stManifestDir « StreamingManifest.json »             if (Test-Path $stManifestPath) {                 Write-Host « Vérification des modifications depuis la dernière exécution du streaming... » -ForegroundColor Cyan                 $stOldManifest = Get-FileManifest -Chemin d'$stManifestPath                 if ($stOldManifest.Count -gt 0) {                     $stChanged = $false                     # Case activée rapide : même nombre de fichiers ?                     if ($stOldManifest.Count -eq $totalFiles) {                         # Vérifiez les 100 fichiers NEWEST (triés par LastWriteTime décroissant)                         # Si un fichier a changé, il aura l’horodatage le plus récent et apparaîtra en premier                         $sampleSize = [math] ::Min(100, $totalFiles)                         $sampleFiles = $jsonFiles | Sort-Object LastWriteTimeUtc - Décroissant | Select-Object -Premier $sampleSize                         foreach ($sf dans $sampleFiles) {                             $sfKey = $sf. FullName.ToLowerInvariant()                             if (-not $stOldManifest.ContainsKey($sfKey)) {                                 $stChanged = $true                                 Pause                             }                             # Comparer les horodatages - mis en cache peut être DateTime ou chaîne après un aller-retour JSON                             $cachedLWT = $stOldManifest[$sfKey]. LastWriteTimeUtc                             $fileDT = $sf. LastWriteTimeUtc                             try {                                 # Si le cache a déjà la valeur DateTime (ConvertFrom-Json convertit automatiquement), utilisez directement                                 if ($cachedLWT -is [DateTime]) {                                     $cachedDT = $cachedLWT.ToUniversalTime()                                 } else {                                     $cachedDT = [DateTimeOffset] ::P arse(« $cachedLWT »). UtcDateTime                                 }                                 if ([math] ::Abs(($cachedDT - $fileDT). TotalSeconds) -gt 1) {                                     $stChanged = $true                                     Pause                                 }                             } catch {                                 $stChanged = $true                                 Pause                             }                         }                     } else {                         $stChanged = $true                     }                     if (-not $stChanged) {                         # Vérifier s’il existe des fichiers de sortie                         $stSummaryExists = Get-ChildItem (Join-Path $OutputPath « SecureBoot_Summary_*.csv ») -EA SilentlyContinue | Select-Object -First 1                         $stDashExists = Get-ChildItem (Join-Path $OutputPath « SecureBoot_Dashboard_*.html ») -EA SilentlyContinue | Select-Object -First 1                         if ($stSummaryExists -and $stDashExists) {                             Write-Host « Aucune modification détectée (fichiers $totalFiles inchangés) - Ignorer le traitement » -ForegroundColor Vert                             Write-Host « Last dashboard : $($stDashExists.FullName) » -ForegroundColor White                             $cachedStats = Get-Content $stSummaryExists.FullName | ConvertFrom-CSV                             Write-Host " Appareils : $($cachedStats.TotalDevices) | Mise à jour : $($cachedStats.Updated) | Erreurs : $($cachedStats.WithErrors)" -ForegroundColor Gray                             Write-Host « Completed in $([math] ::Round($streamSw.Elapsed.TotalSeconds, 1))s (aucun traitement nécessaire) » -ForegroundColor Green                             $cachedStats de retour                         }                     } else {                         # DELTA PATCH : Rechercher exactement les fichiers modifiés                         Write-Host « Modifications détectées - Identification des fichiers modifiés... » -ForegroundColor Yellow                         $changedFiles = [System.Collections.ArrayList] ::new()                         $newFiles = [System.Collections.ArrayList] ::new()                         foreach ($jf dans $jsonFiles) {                             $jfKey = $jf. FullName.ToLowerInvariant()                             if (-not $stOldManifest.ContainsKey($jfKey)) {                                 [void]$newFiles.Add($jf)                             } else {                                 $cachedLWT = $stOldManifest[$jfKey]. LastWriteTimeUtc                                 $fileDT = $jf. LastWriteTimeUtc                                 try {                                     $cachedDT = if ($cachedLWT -is [DateTime]) { $cachedLWT.ToUniversalTime() } else { [DateTimeOffset] ::P arse(« $cachedLWT »). UtcDateTime }                                     if ([math] ::Abs(($cachedDT - $fileDT). TotalSeconds) -gt 1) { [void]$changedFiles.Add($jf) }                                 } catch { [void]$changedFiles.Add($jf) }                             }                         }                         $totalChanged = $changedFiles.Count + $newFiles.Count                         $changePct = [math] ::Round(($totalChanged / $totalFiles) * 100, 1)                         Write-Host " Modifié : $($changedFiles.Count) | Nouveau : $($newFiles.Count) | Total : $totalChanged ($changePct %) " -ForegroundColor Yellow                         if ($totalChanged -gt 0 -and $changePct -lt 10) {                             # MODE DE CORRECTIF DELTA : <10 % modifié, corriger les données existantes                             Write-Host « Mode de correctif delta ($changePct % < 10 %) - Mise à jour corrective des fichiers $totalChanged... - ForegroundColor Vert                             $dataDir = Join-Path $OutputPath « data »                             Nombre de chargements d’enregistrements d’appareil modifiés/nouveaux                             $deltaDevices = @{}                             $allDeltaFiles = @($changedFiles) + @($newFiles)                             foreach ($df dans $allDeltaFiles) {                                 try {                                     $devData = Get-Content $df. FullName -Raw | ConvertFrom-Json                                     $dev = Normalize-DeviceRecord $devData                                     if ($dev. HostName) { $deltaDevices[$dev. HostName] = $dev }                                 } catch { }                             }                             Write-Host « Enregistrements d’appareil modifiés $($deltaDevices.Count) chargés » -ForegroundColor Gray                             # Pour chaque catégorie JSON : supprimer les anciennes entrées pour les noms d’hôte modifiés, ajouter de nouvelles entrées                             $categoryFiles = @(« errors », « known_issues », « missing_kek », « not_updated »,                                 « task_disabled », « temp_failures », « perm_failures », « updated_devices »,                                 « action_required », « secureboot_off », « rollout_inprogress »)                             $changedHostnames = [System.Collections.Generic.HashSet[string]] ::new([System.StringComparer] ::OrdinalIgnoreCase)                             foreach ($hn dans $deltaDevices.Keys) { [void]$changedHostnames.Add($hn) }                             foreach ($cat dans $categoryFiles) {                                 $catPath = Join-Path $dataDir « $cat.json »                                 if (Test-Path $catPath) {                                     try {                                         $catData = Get-Content $catPath -Raw | ConvertFrom-Json                                         # Supprimer les anciennes entrées pour les noms d’hôte modifiés                                         $catData = @($catData | Where-Object { -not $changedHostnames.Contains($_. HostName) })                                         # Reclassifier chaque appareil modifié en catégories                                         # (sera ajouté ci-dessous après la classification)                                         $catData | ConvertTo-Json -Profondeur 5 | Set-Content $catPath -Encodage UTF8                                     } catch { }                                 }                             }                             # Classifier chaque appareil modifié et ajouter aux fichiers de catégorie appropriés                             foreach ($dev dans $deltaDevices.Values) {                                 $slim = [ordonné]@{                                     HostName = $dev. Hostname                                     WMI_Manufacturer = if ($dev. PSObject.Properties['WMI_Manufacturer']) { $dev. WMI_Manufacturer } else { «  » }                                     WMI_Model = if ($dev. PSObject.Properties['WMI_Model']) { $dev. WMI_Model } else { «  » }                                     BucketId = if ($dev. PSObject.Properties['BucketId']) { $dev. BucketId } else { «  » }                                     ConfidenceLevel = if ($dev. PSObject.Properties['ConfidenceLevel']) { $dev. ConfidenceLevel } else { «  » }                                     IsUpdated = $dev. IsUpdated                                     UEFICA2023Error = if ($dev. PSObject.Properties['UEFICA2023Error']) { $dev. UEFICA2023Error } else { $null }                                     SecureBootTaskStatus = if ($dev. PSObject.Properties['SecureBootTaskStatus']) { $dev. SecureBootTaskStatus } else { «  » }                                     KnownIssueId = if ($dev. PSObject.Properties['KnownIssueId']) { $dev. KnownIssueId } else { $null }                                     SkipReasonKnownIssue = if ($dev. PSObject.Properties['SkipReasonKnownIssue']) { $dev. SkipReasonKnownIssue } else { $null }                                 }                                 $isUpd = $dev. IsUpdated -eq $true                                 $conf = if ($dev. PSObject.Properties['ConfidenceLevel']) { $dev. ConfidenceLevel } else { «  » }                                 $hasErr = (-not [string] ::IsNullOrEmpty($dev. UEFICA2023Error) et $dev. UEFICA2023Error -ne « 0 » -and $dev. UEFICA2023Error -ne «  »)                                 $tskDis = ($dev. SecureBootTaskEnabled -eq $false -or $dev. SecureBootTaskStatus -eq 'Disabled' -or $dev. SecureBootTaskStatus -eq 'NotFound')                                 $tskNF = ($dev. SecureBootTaskStatus -eq 'NotFound')                                 $sbOn = ($dev. SecureBootEnabled -ne $false -et "$($dev. SecureBootEnabled) » -ne « False »)                                 $e 1801 = if ($dev. PSObject.Properties['Event1801Count']) { [int]$dev. Event1801Count } else { 0 }                                 $e 1808 = if ($dev. PSObject.Properties['Event1808Count']) { [int]$dev. Event1808Count } else { 0 }                                 $e 1803 = if ($dev. PSObject.Properties['Event1803Count']) { [int]$dev. Event1803Count } else { 0 }                                 $mKEK = ($e 1803 -gt 0 -ou $dev. MissingKEK -eq $true)                                 $hKI = ((-not [string] ::IsNullOrEmpty($dev. SkipReasonKnownIssue)) -ou (-not [string] ::IsNullOrEmpty($dev. KnownIssueId)))                                 $rStat = if ($dev. PSObject.Properties['RolloutStatus']) { $dev. RolloutStatus } else { «  » }                                 # Ajouter aux fichiers de catégorie correspondants                                 $targets = @()                                 if ($isUpd) { $targets += « updated_devices » }                                 if ($hasErr) { $targets += « errors » }                                 if ($hKI) { $targets += « known_issues » }                                 if ($mKEK) { $targets += « missing_kek » }                                 if (-not $isUpd -and $sbOn) { $targets += « not_updated » }                                 if ($tskDis) { $targets += « task_disabled » }                                 if (-not $isUpd -and ($tskDis -or (Test-ConfidenceLevel $conf 'TemporarilyPaused'))) { $targets += « temp_failures » }                                 if (-not $isUpd -and ((Test-ConfidenceLevel $conf 'NotSupported') -or ($tskNF -and $hasErr))) { $targets += « perm_failures » }                                 if (-not $isUpd -and (Test-ConfidenceLevel $conf 'ActionRequired')) { $targets += « action_required » }                                 if (-not $sbOn) { $targets += « secureboot_off » }                                 if ($e 1801 -gt 0 -and $e 1808 -eq 0 -and -not $hasErr -and $rStat -eq « InProgress ») { $targets += « rollout_inprogress » }                                 foreach ($tgt dans $targets) {                                     $tgtPath = Join-Path $dataDir « $tgt.json »                                     if (Test-Path $tgtPath) {                                         $existing = Get-Content $tgtPath -Raw | ConvertFrom-Json                                         $existing = @($existing) + @([PSCustomObject]$slim)                                         $existing | ConvertTo-Json -Profondeur 5 | Set-Content $tgtPath -Encodage UTF8                                     }                                 }                             }                             # Régénérer des csv à partir de JSONs corrigés                             Write-Host « Régénération des csv à partir de données corrigées... » -ForegroundColor Gray                             $newTimestamp = Get-Date -Format « aaaaMMdd-HHmmss »                             foreach ($cat dans $categoryFiles) {                                 $catJsonPath = Join-Path $dataDir « $cat.json »                                 $catCsvPath = Join-Path $OutputPath « SecureBoot_${cat}_$newTimestamp.csv »                                 if (test-path $catJsonPath) {                                     try {                                         $catJsonData = Get-Content $catJsonPath -Raw | ConvertFrom-Json                                         if ($catJsonData.Count -gt 0) {                                             $catJsonData | Export-Csv -Path $catCsvPath -NoTypeInformation -Encoding UTF8                                         }                                     } catch { }                                 }                             }                             # Recompter les statistiques des fichiers JSON corrigés                             Write-Host « Recalcul d’un résumé à partir de données corrigées... » -ForegroundColor Gray                             $patchedStats = [ordered]@{ ReportGeneratedAt = (Get-Date). ToString(« aaaa-MM-jj HH :mm :ss ») }                             $pTotal = 0 ; $pUpdated = 0 ; $pErrors = 0 ; $pKI = 0 ; $pKEK = 0                             $pTaskDis = 0 ; $pTempFail = 0 ; $pPermFail = 0 ; $pActionReq = 0 ; $pSBOff = 0 ; $pRIP = 0                             foreach ($cat dans $categoryFiles) {                                 $catPath = Join-Path $dataDir « $cat.json »                                 $cnt = 0                                 if (Test-Path $catPath) { try { $cnt = (Get-Content $catPath -Raw | ConvertFrom-Json). Count } catch { } }                                 switch ($cat) {                                     « updated_devices » { $pUpdated = $cnt }                                     « errors » { $pErrors = $cnt }                                     « known_issues » { $pKI = $cnt }                                     « missing_kek » { $pKEK = $cnt }                                     « not_updated » { } # calculé                                     « task_disabled » { $pTaskDis = $cnt }                                     « temp_failures » { $pTempFail = $cnt }                                     « perm_failures » { $pPermFail = $cnt }                                     « action_required » { $pActionReq = $cnt }                                     « secureboot_off » { $pSBOff = $cnt }                                     « rollout_inprogress » { $pRIP = $cnt }                                 }                             }                             $pNotUpdated = (Get-Content (Join-Path $dataDir « not_updated.json ») -Raw | ConvertFrom-Json). Compter                             $pTotal = $pUpdated + $pNotUpdated + $pSBOff                             Write-Host « Correctif delta terminé : $totalChanged appareils mis à jour » -ForegroundColor Vert                             Write-Host " Total : $pTotal | Mise à jour : $pUpdated | NotUpdated : $pNotUpdated | Erreurs : $pErrors" -ForegroundColor White                             # Mettre à jour le manifeste                             $stManifestDir = Join-Path $OutputPath .cache"                             $stNewManifest = @{}                             foreach ($jf dans $jsonFiles) {                                 $stNewManifest[$jf. FullName.ToLowerInvariant()] = @{                                     LastWriteTimeUtc = $jf. LastWriteTimeUtc.ToString(« o ») ; Taille = $jf. Longueur                                 }                             }                             Save-FileManifest -Manifest $stNewManifest -Path $stManifestPath                             Write-Host " Completed in $([math] ::Round($streamSw.Elapsed.TotalSeconds, 1))s (delta patch - $totalChanged devices) » -ForegroundColor Green                             # Effectuer un retraitement de streaming complet pour régénérer le tableau de bord HTML                             # Les fichiers de données sont déjà corrigés, ce qui garantit que le tableau de bord reste à jour                             Write-Host « Régénération du tableau de bord à partir de données corrigées... » -ForegroundColor Yellow                         } else {                             Write-Host « $changePct % files changed (>= 10 %) - full streaming reprocess required » -ForegroundColor Yellow                         }                     }                 }             }         }         # Créer un sous-répertoire de données pour les fichiers JSON d’appareil à la demande         $dataDir = Join-Path $OutputPath « data »         if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir -Force | Out-Null }         # Déduplication via HashSet (O(1) par recherche, ~50 Mo pour 600 000 noms d’hôte)         $seenHostnames = [System.Collections.Generic.HashSet[string]] ::new([System.StringComparer] ::OrdinalIgnoreCase)         # Compteurs résumés légers (remplace $allDevices + $uniqueDevices en mémoire)         $c = @{             Total = 0 ; SBEnabled = 0 ; SBOff = 0             Mise à jour = 0 ; HighConf = 0 ; UnderObs = 0 ; ActionReq = 0 ; TempPaused = 0 ; NotSupported = 0 ; NoConfData = 0             TaskDisabled = 0 ; TaskNotFound = 0 ; TaskDisabledNotUpdated = 0             WithErrors = 0 ; InProgress = 0 ; NotYetInitiated = 0 ; RolloutInProgress = 0             WithKnownIssues = 0 ; WithMissingKEK = 0 ; TempFailures = 0 ; PermFailures = 0 ; NeedsReboot = 0             UpdatePending = 0         }         # Suivi des compartiments pour AtRisk/SafeList (jeux légers)         $stFailedBuckets = [System.Collections.Generic.HashSet[string]] ::new()         $stSuccessBuckets = [System.Collections.Generic.HashSet[string]] ::new()         $stAllBuckets = @{}         $stMfrCounts = @{}         $stErrorCodeCounts = @{} ; $stErrorCodeSamples = @{}         $stKnownIssueCounts = @{}         Nombre de fichiers de données d’appareil en mode Batch : accumulation par bloc, vidage aux limites des blocs         $stDeviceFiles = @(« errors », « known_issues », « missing_kek », « not_updated »,             « task_disabled », « temp_failures », « perm_failures », « updated_devices », « action_required »,             « secureboot_off », « rollout_inprogress », « under_observation », « needs_reboot », « update_pending »)         $stDeviceFilePaths = @{} ; $stDeviceFileCounts = @{}         foreach ($dfName dans $stDeviceFiles) {             $dfPath = Join-Path $dataDir « $dfName.json »             [System.IO.File] ::WriteAllText($dfPath, « ['n », [System.Text.Encoding] ::UTF8)             $stDeviceFilePaths[$dfName] = $dfPath ; $stDeviceFileCounts[$dfName] = 0         }         # Enregistrement d’appareil mince pour la sortie JSON (uniquement les champs essentiels, ~200 octets vs ~2 Ko complet)         function Get-SlimDevice {             param($Dev)             return [ordered]@{                 HostName = $Dev.HostName                 WMI_Manufacturer = if ($Dev.PSObject.Properties['WMI_Manufacturer']) { $Dev.WMI_Manufacturer } else { «  » }                 WMI_Model = if ($Dev.PSObject.Properties['WMI_Model']) { $Dev.WMI_Model } else { «  » } }                 BucketId = if ($Dev.PSObject.Properties['BucketId']) { $Dev.BucketId } else { «  » }                 ConfidenceLevel = if ($Dev.PSObject.Properties['ConfidenceLevel']) { $Dev.ConfidenceLevel } else { «  » }                 IsUpdated = $Dev.IsUpdated                 UEFICA2023Error = if ($Dev.PSObject.Properties['UEFICA2023Error']) { $Dev.UEFICA2023Error } else { $null }                 SecureBootTaskStatus = if ($Dev.PSObject.Properties['SecureBootTaskStatus']) { $Dev.SecureBootTaskStatus } else { «  » }                 KnownIssueId = if ($Dev.PSObject.Properties['KnownIssueId']) { $Dev.KnownIssueId } else { $null }                 SkipReasonKnownIssue = if ($Dev.PSObject.Properties['SkipReasonKnownIssue']) { $Dev.SkipReasonKnownIssue } else { $null }                 UEFICA2023Status = if ($Dev.PSObject.Properties['UEFICA2023Status']) { $Dev.UEFICA2023Status } else { $null }                 AvailableUpdatesPolicy = if ($Dev.PSObject.Properties['AvailableUpdatesPolicy']) { $Dev.AvailableUpdatesPolicy } else { $null }                 WinCSKeyApplied = if ($Dev.PSObject.Properties['WinCSKeyApplied']) { $Dev.WinCSKeyApplied } else { $null }             }         }         # Vider le lot dans un fichier JSON (mode ajout)         function Flush-DeviceBatch {             param([string]$StreamName, [System.Collections.Generic.List[object]]$Batch)             if ($Batch.Count -eq 0) { return }             $fPath = $stDeviceFilePaths[$StreamName]             $fSb = [System.Text.StringBuilder] ::new()             foreach ($fDev dans $Batch) {                 if ($stDeviceFileCounts[$StreamName] -gt 0) { [void]$fSb.Append(« ,'n ») }                 [void]$fSb.Append(($fDev | ConvertTo-Json -Compress))                 $stDeviceFileCounts[$StreamName]++             }             [System.IO.File] ::AppendAllText($fPath, $fSb.ToString(), [System.Text.Encoding] ::UTF8)         }         # BOUCLE DE STREAMING PRINCIPALE         $stChunkSize = if ($totalFiles -le 10000) { $totalFiles } else { 10000 }         $stTotalChunks = [math] ::Ceiling($totalFiles / $stChunkSize)         $stPeakMemMB = 0         if ($stTotalChunks -gt 1) {             Write-Host « Traitement des fichiers $totalFiles dans $stTotalChunks blocs de $stChunkSize (diffusion en continu, threads $ParallelThreads) » -ForegroundColor Cyan         } else {             Write-Host « Traitement des fichiers $totalFiles (streaming, $ParallelThreads threads) » -ForegroundColor Cyan         }         for ($ci = 0 ; $ci -lt $stTotalChunks ; $ci++) {             $cStart = $ci * $stChunkSize             $cEnd = [math] ::Min($cStart + $stChunkSize, $totalFiles) - 1             $cFiles = $jsonFiles[$cStart.. $cEnd]             if ($stTotalChunks -gt 1) {                 Write-Host " Chunk $($ci + 1)/$stTotalChunks ($($cFiles.Count) files)) : " -NoNewline -ForegroundColor Gray             } else {                 Write-Host " Chargement des fichiers $($cFiles.Count) : " -NoNewline -ForegroundColor Gray             }             $cSw = [System.Diagnostics.Stopwatch] ::StartNew()             $rawDevices = Load-FilesParallel -Files $cFiles -Threads $ParallelThreads             Nombre de listes de lots par bloc             $cBatches = @{}             foreach ($df in $stDeviceFiles) { $cBatches[$df] = [System.Collections.Generic.List[object]] ::new() }             $cNew = 0 ; $cDupe = 0             foreach ($raw dans $rawDevices) {                 if (-not $raw) { continue }                 $device = Normalize-DeviceRecord $raw                 $hostname = $device. Hostname                 if (-not $hostname) { continue }                 if ($seenHostnames.Contains($hostname)) { $cDupe++ ; continue }                 [void]$seenHostnames.Add($hostname)                 $cNew++ ; $c.Total++                 $sbOn = ($device. SecureBootEnabled -ne $false -and "$($device. SecureBootEnabled) » -ne « False »)                 if ($sbOn) { $c.SBEnabled++ } else { $c.SBOff++ ; $cBatches["secureboot_off"]. Add((Get-SlimDevice $device)) }                 $isUpd = $device. IsUpdated -eq $true                 $conf = if ($device. PSObject.Properties['ConfidenceLevel'] - et $device. ConfidenceLevel) { "$($device. ConfidenceLevel) » } else { «  » }                 $hasErr = (-not [string] ::IsNullOrEmpty($device. UEFICA2023Error) -and "$($device. UEFICA2023Error) » -ne « 0 » -and « $($device. UEFICA2023Error) » -ne «  »)                 $tskDis = ($device. SecureBootTaskEnabled -eq $false -or "$($device. SecureBootTaskStatus) » -eq 'Disabled' -or « $($device. SecureBootTaskStatus)" -eq 'NotFound')                 $tskNF = ("$($device. SecureBootTaskStatus)" -eq 'NotFound')                 $bid = if ($device. PSObject.Properties['BucketId'] -and $device. BucketId) { "$($device. BucketId) » } else { «  » }                 $e 1808 = if ($device. PSObject.Properties['Event1808Count']) { [int]$device. Event1808Count } else { 0 }                 $e 1801 = if ($device. PSObject.Properties['Event1801Count']) { [int]$device. Event1801Count } else { 0 }                 $e 1803 = if ($device. PSObject.Properties['Event1803Count']) { [int]$device. Event1803Count } else { 0 }                 $mKEK = ($e 1803 -gt 0 -ou $device. MissingKEK -eq $true -or "$($device. MissingKEK) » -eq « True »)                 $hKI = ((-not [string] ::IsNullOrEmpty($device. SkipReasonKnownIssue)) -ou (-not [string] ::IsNullOrEmpty($device. KnownIssueId)))                 $rStat = if ($device. PSObject.Properties['RolloutStatus']) { $device. RolloutStatus } else { «  » }                 $mfr = if ($device. PSObject.Properties['WMI_Manufacturer'] -and -not [string] ::IsNullOrEmpty($device. WMI_Manufacturer)) { $device. WMI_Manufacturer } else { « Unknown » }                 $bid = if (-not [string] ::IsNullOrEmpty($bid)) { $bid } else { «  » }                 Indicateur # Mise à jour de pré-calcul En attente (stratégie/WinCS appliquée, status pas encore mise à jour, SB ON, tâche non désactivée)                 $uefiStatus = if ($device. PSObject.Properties['UEFICA2023Status']) { "$($device. UEFICA2023Status) » } else { «  » }                 $hasPolicy = ($device. PSObject.Properties['AvailableUpdatesPolicy'] -and $null -ne $device. AvailableUpdatesPolicy -and "$($device. AvailableUpdatesPolicy) " -ne '')                 $hasWinCS = ($device. PSObject.Properties['WinCSKeyApplied'] - et $device. WinCSKeyApplied -eq $true)                 $statusPending = ([string] ::IsNullOrEmpty($uefiStatus) -or $uefiStatus -eq 'NotStarted' -or $uefiStatus -eq 'InProgress')                 $isUpdatePending = ((($hasPolicy -or $hasWinCS) -and $statusPending -and -not $isUpd -and $sbOn -and -not $tskDis)                 if ($isUpd) {                     $c.Updated++ ; [void]$stSuccessBuckets.Add($bid) ; $cBatches["updated_devices"]. Add((Get-SlimDevice $device))                     # Suivre les appareils mis à jour qui doivent être redémarrés (UEFICA2023Status=Mis à jour, mais Event1808=0)                     if ($e 1808 -eq 0) { $c.NeedsReboot++ ; $cBatches["needs_reboot"]. Add((Get-SlimDevice $device)) }                 }                 elseif (-not $sbOn) {                     # SecureBoot OFF : hors de portée, ne classifiez pas par confiance                 }                 else {                     if ($isUpdatePending) { } # Compté séparément dans Mise à jour en attente — mutuellement exclusif pour le graphique en secteurs                     elseif (Test-ConfidenceLevel $conf « HighConfidence ») { $c.HighConf++ }                     elseif (Test-ConfidenceLevel $conf « UnderObservation ») { $c.UnderObs++ }                     elseif (Test-ConfidenceLevel $conf « TemporarilyPaused ») { $c.TempPaused++ }                     elseif (Test-ConfidenceLevel $conf « NotSupported ») { $c.NotSupported++ }                     else { $c.ActionReq++ }                     if ([string] ::IsNullOrEmpty($conf)) { $c.NoConfData++ }                 }                 if ($tskDis) { $c.TaskDisabled++ ; $cBatches["task_disabled"]. Add((Get-SlimDevice $device)) }                 if ($tskNF) { $c.TaskNotFound++ }                 if (-not $isUpd -and $tskDis) { $c.TaskDisabledNotUpdated++ }                 if ($hasErr) {                     $c.WithErrors++ ; [void]$stFailedBuckets.Add($bid) ; $cBatches["errors"]. Add((Get-SlimDevice $device))                     $ec = $device. UEFICA2023Error                     if (-not $stErrorCodeCounts.ContainsKey($ec)) { $stErrorCodeCounts[$ec] = 0 ; $stErrorCodeSamples[$ec] = @() }                     $stErrorCodeCounts[$ec]++                     if ($stErrorCodeSamples[$ec]. Count -lt 5) { $stErrorCodeSamples[$ec] += $hostname }                 }                 if ($hKI) {                     $c.WithKnownIssues++ ; $cBatches["known_issues"]. Add((Get-SlimDevice $device))                     $ki = if (-not [string] ::IsNullOrEmpty($device. SkipReasonKnownIssue)) { $device. SkipReasonKnownIssue } else { $device. KnownIssueId }                     if (-not $stKnownIssueCounts.ContainsKey($ki)) { $stKnownIssueCounts[$ki] = 0 } ; $stKnownIssueCounts[$ki]++                 }                 if ($mKEK) { $c.WithMissingKEK++ ; $cBatches["missing_kek"]. Add((Get-SlimDevice $device)) }                 if (-not $isUpd -and ($tskDis -or (Test-ConfidenceLevel $conf 'TemporarilyPaused'))) { $c.TempFailures++ ; $cBatches["temp_failures"]. Add((Get-SlimDevice $device)) }                 if (-not $isUpd -and ((Test-ConfidenceLevel $conf 'NotSupported') -or ($tskNF -and $hasErr))) { $c.PermFailures++ ; $cBatches["perm_failures"]. Add((Get-SlimDevice $device)) }                 if ($e 1801 -gt 0 -and $e 1808 -eq 0 -and -not $hasErr -and $rStat -eq « InProgress ») { $c.RolloutInProgress++ ; $cBatches["rollout_inprogress"]. Add((Get-SlimDevice $device)) }                 if ($e 1801 -gt 0 -and $e 1808 -eq 0 -and -not $hasErr -and $rStat -ne « InProgress ») { $c.NotYetInitiated++ }                 if ($rStat -eq « InProgress » -and $e 1808 -eq 0) { $c.InProgress++ }                 # Mise à jour en attente : stratégie ou WinCS appliquée, status en attente, SB ON, tâche non désactivée                 if ($isUpdatePending) {                     $c.UpdatePending++ ; $cBatches["update_pending"]. Add((Get-SlimDevice $device))                 }                 if (-not $isUpd -and $sbOn) { $cBatches["not_updated"]. Add((Get-SlimDevice $device)) }                 Nombre d’appareils sous observation (distincts de l’action requise)                 if (-not $isUpd -and (Test-ConfidenceLevel $conf 'UnderObservation')) { $cBatches["under_observation"]. Add((Get-SlimDevice $device)) }                 # Action requise : non mis à jour, SB ON, ne correspondant pas à d’autres catégories de confiance, pas Mise à jour en attente                 si (-pas $isUpd -et $sbOn -and -not $isUpdatePending -and -not (Test-ConfidenceLevel $conf 'HighConfidence') -and -not (Test-ConfidenceLevel $conf 'UnderObservation') -and -not (Test-ConfidenceLevel $conf 'TemporarilyPaused') -and -not (Test-ConfidenceLevel $conf 'NotSupported')) {                     $cBatches["action_required"]. Add((Get-SlimDevice $device))                 }                 if (-not $stMfrCounts.ContainsKey($mfr)) { $stMfrCounts[$mfr] = @{ Total=0 ; Mise à jour =0 ; UpdatePending=0 ; HighConf=0 ; UnderObs=0 ; ActionReq=0 ; TempPaused=0 ; NotSupported=0 ; SBOff=0 ; WithErrors=0 } }                 $stMfrCounts[$mfr]. Total++                 if ($isUpd) { $stMfrCounts[$mfr]. Mis à jour++ }                 elseif (-not $sbOn) { $stMfrCounts[$mfr]. SBOff++ }                 elseif ($isUpdatePending) { $stMfrCounts[$mfr]. UpdatePending++ }                 elseif (Test-ConfidenceLevel $conf « HighConfidence ») { $stMfrCounts[$mfr]. HighConf++ }                 elseif (Test-ConfidenceLevel $conf « UnderObservation ») { $stMfrCounts[$mfr]. UnderObs++ }                 elseif (Test-ConfidenceLevel $conf « TemporarilyPaused ») { $stMfrCounts[$mfr]. TempPaused++ }                 elseif (Test-ConfidenceLevel $conf « NotSupported ») { $stMfrCounts[$mfr]. NotSupported++ }                 else { $stMfrCounts[$mfr]. ActionReq++ }                 if ($hasErr) { $stMfrCounts[$mfr]. WithErrors++ }                 # Suivre tous les appareils par compartiment (y compris BucketId vide)                 $bucketKey = if ($bid -and $bid -ne «  ») { $bid } else { « (empty) » } }                 if (-not $stAllBuckets.ContainsKey($bucketKey)) {                     $stAllBuckets[$bucketKey] = @{ Count=0 ; Mise à jour =0 ; Manufacturer=$mfr ; Model=" » ; BIOS=" » }                     if ($device. PSObject.Properties['WMI_Model']) { $stAllBuckets[$bucketKey]. Modèle = $device. WMI_Model }                     if ($device. PSObject.Properties['BIOSDescription']) { $stAllBuckets[$bucketKey]. BIOS = $device. BIOSDescription }                 }                 $stAllBuckets[$bucketKey]. Count++                 if ($isUpd) { $stAllBuckets[$bucketKey]. Mis à jour++ }             }             # Vider les lots sur le disque             foreach ($df en $stDeviceFiles) { Flush-DeviceBatch -StreamName $df -Batch $cBatches[$df] }             $rawDevices = $null ; $cBatches = $null ; [System.GC] ::Collect()             $cSw.Stop()             $cTime = [Math] ::Round($cSw.Elapsed.TotalSeconds, 1)             $cRem = $stTotalChunks - $ci - 1             $cEta = if ($cRem -gt 0) { " | ETA : ~$([Math] ::Round($cRem * $cSw.Elapsed.TotalSeconds / 60, 1)) min » } else { «  » }             $cMem = [math] ::Round([System.GC] ::GetTotalMemory($false) / 1 Mo, 0)             if ($cMem -gt $stPeakMemMB) { $stPeakMemMB = $cMem }             Write-Host " + $cNew nouveau, $cDupe dupes, ${cTime}s | Mem : ${cMem}MB$cEta" -ForegroundColor Vert         }         Nombre de tableaux JSON finalisés         foreach ($dfName dans $stDeviceFiles) {             [System.IO.File] ::AppendAllText($stDeviceFilePaths[$dfName], « 'n] », [System.Text.Encoding] ::UTF8)             Write-Host " $dfName.json : $($stDeviceFileCounts[$dfName]) devices » -ForegroundColor DarkGray         }         Nombre de statistiques dérivées du calcul         $stAtRisk = 0 ; $stSafeList = 0         foreach ($bid dans $stAllBuckets.Keys) {             $b = $stAllBuckets[$bid] ; $nu = $b.Count - $b.Updated             if ($stFailedBuckets.Contains($bid)) { $stAtRisk += $nu }             elseif ($stSuccessBuckets.Contains($bid)) { $stSafeList += $nu }         }         $stAtRisk = [math] ::Max(0, $stAtRisk - $c.WithErrors)         # NotUptodate = count from not_updated batch (appareils avec SB ON et non mis à jour)         $stNotUptodate = $stDeviceFileCounts["not_updated"]         $stats = [ordonné]@{             ReportGeneratedAt = (Get-Date). ToString(« aaaa-MM-jj HH :mm :ss »)             TotalDevices = $c.Total ; SecureBootEnabled = $c.SBEnabled ; SecureBootOFF = $c.SBOff             Updated = $c.Updated ; HighConfidence = $c.HighConf ; UnderObservation = $c.UnderObs             ActionRequired = $c.ActionReq ; TemporarilyPaused = $c.TempPaused ; NotSupported = $c.NotSupported             NoConfidenceData = $c.NoConfData ; TaskDisabled = $c.TaskDisabled ; TaskNotFound = $c.TaskNotFound             TaskDisabledNotUpdated = $c.TaskDisabledNotUpdated             CertificatesUpdated = $c.Updated ; NotUptodate = $stNotUptodate ; FullyUpdated = $c.Updated             UpdatesPending = $stNotUptodate ; UpdatesComplete = $c.Updated             WithErrors = $c.WithErrors ; InProgress = $c.InProgress ; NotYetInitiated = $c.NotYetInitiated             RolloutInProgress = $c.RolloutInProgress ; WithKnownIssues = $c.WithKnownIssues             WithMissingKEK = $c.WithMissingKEK ; TemporaryFailures = $c.TempFailures ; PermanentFailures = $c.PermFailures             NeedsReboot = $c.NeedsReboot ; UpdatePending = $c.UpdatePending             AtRiskDevices = $stAtRisk ; SafeListDevices = $stSafeList             PercentWithErrors = if ($c.Total -gt 0) { [math] ::Round(($c.WithErrors/$c.Total)*100,2) } else { 0 }             PercentAtRisk = if ($c.Total -gt 0) { [math] ::Round(($stAtRisk/$c.Total)*100,2) } else { 0 }             PercentSafeList = if ($c.Total -gt 0) { [math] ::Round(($stSafeList/$c.Total)*100,2) } else { 0 }             PercentHighConfidence = if ($c.Total -gt 0) { [math] ::Round(($c.HighConf/$c.Total)*100,1) } else { 0 }             PercentCertUpdated = if ($c.Total -gt 0) { [math] ::Round(($c.Updated/$c.Total)*100,1) } else { 0 }             PercentActionRequired = if ($c.Total -gt 0) { [math] ::Round(($c.ActionReq/$c.Total)*100,1) } else { 0 }             PercentNotUptodate = if ($c.Total -gt 0) { [math] ::Round($stNotUptodate/$c.Total*100,1) } else { 0 }             PercentFullyUpdated = if ($c.Total -gt 0) { [math] ::Round(($c.Updated/$c.Total)*100,1) } else { 0 }             UniqueBuckets = $stAllBuckets.Count ; PeakMemoryMB = $stPeakMemMB ; ProcessingMode = « Streaming »         }         Nombre de csv d’écriture         [PSCustomObject]$stats | Export-Csv -Path (Join-Path $OutputPath « SecureBoot_Summary_$timestamp.csv ») -NoTypeInformation -Encoding UTF8         $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Décroissant | ForEach-Object {             [PSCustomObject]@{ Manufacturer=$_. Clé; Count=$_. Value.Total ; Updated=$_. Value.Updated ; HighConfidence=$_. Value.HighConf ; ActionRequired=$_. Value.ActionReq }         } | Export-Csv -Path (Join-Path $OutputPath « SecureBoot_ByManufacturer_$timestamp.csv ») -NoTypeInformation -Encoding UTF8         $stErrorCodeCounts.GetEnumerator() | valeur Sort-Object -Décroissante | ForEach-Object {             [PSCustomObject]@{ ErrorCode=$_. Clé; Count=$_. Valeur; SampleDevices=($stErrorCodeSamples[$_. Clé] -join « , « ) }         } | Export-Csv -Path (Join-Path $OutputPath « SecureBoot_ErrorCodes_$timestamp.csv ») -NoTypeInformation -Encoding UTF8         $stAllBuckets.GetEnumerator() | Sort-Object { $_. Value.Count } -Décroissant | ForEach-Object {             [PSCustomObject]@{ BucketId=$_. Clé; Count=$_. Value.Count ; Updated=$_. Value.Updated ; NotUpdated=$_. Value.Count-$_. Value.Updated ; Manufacturer=$_. Value.Manufacturer }         } | Export-Csv -Path (Join-Path $OutputPath « SecureBoot_UniqueBuckets_$timestamp.csv ») -NoTypeInformation -Encoding UTF8         # Générer des csv compatibles avec orchestrateur (noms de fichiers attendus pour Start-SecureBootRolloutOrchestrator.ps1)         $notUpdatedJsonPath = Join-Path $dataDir « not_updated.json »         if (Test-Path $notUpdatedJsonPath) {             try {                 $nuData = Get-Content $notUpdatedJsonPath -Raw | ConvertFrom-Json                 if ($nuData.Count -gt 0) {                     Csv # NotUptodate : l’orchestrateur recherche *NotUptodate*.csv                     $nuData | Export-Csv -Path (Join-Path $OutputPath « SecureBoot_NotUptodate_$timestamp.csv ») -NoTypeInformation -Encoding UTF8                     Write-Host « Orchestrator CSV : SecureBoot_NotUptodate_$timestamp.csv (appareils $($nuData.Count) ) » -ForegroundColor Gray                 }             } catch { }         }         # Écrire des données JSON pour le tableau de bord         $stats | ConvertTo-Json -Profondeur 3 | Set-Content (Join-Path $dataDir « summary.json ») - Encodage UTF8         # SUIVI HISTORIQUE : Enregistrer le point de données pour le graphique de tendance         # Utilisez un emplacement de cache stable pour que les données de tendance persistent dans les dossiers d’agrégation horodatés.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   # Si OutputPath ressemble à « ...\Aggregation_yyyyMMdd_HHmmss », le cache est placé dans le dossier parent.# Sinon, le cache passe à l’intérieur de OutputPath lui-même.$parentDir = Split-Path $OutputPath -Parent         $leafName = Split-Path $OutputPath -Leaf         if ($leafName -match '^Aggregation_\d{8}' -or $leafName -eq 'Aggregation_Current') {             # Dossier horodaté créé par Orchestrator : utilisez le parent pour le cache stable             $historyPath = Join-Path $parentDir .cache\trend_history.json"         } else {             $historyPath = Join-Path $OutputPath .cache\trend_history.json"         }         $historyDir = Split-Path $historyPath -Parent         if (-not (Test-Path $historyDir)) { New-Item -ItemType Directory -Path $historyDir -Force | Out-Null }         $historyData = @()         if (Test-Path $historyPath) {             try { $historyData = @(Get-Content $historyPath -Raw | ConvertFrom-Json) } catch { $historyData = @() }         }         # Également case activée dans OutputPath\.cache\ (emplacement hérité des versions antérieures)         # Fusionner tous les points de données qui ne se trouvent pas déjà dans l’historique principal         if ($leafName -eq 'Aggregation_Current' -or $leafName -match '^Aggregation_\d{8}') {             $innerHistoryPath = Join-Path $OutputPath .cache\trend_history.json"             if ((Test-Path $innerHistoryPath) -and $innerHistoryPath -ne $historyPath) {                 try {                     $innerData = @(Get-Content $innerHistoryPath -Raw | ConvertFrom-Json)                     $existingDates = @($historyData | ForEach-Object { $_. Date })                     foreach ($entry dans $innerData) {                         if ($entry. Date et $entry. Date -notin $existingDates) {                             $historyData += $entry                         }                     }                     if ($innerData.Count -gt 0) {                         Write-Host « Points de données $($innerData.Count) fusionnés à partir du cache interne » -ForegroundColor DarkGray                     }                 } catch { }             }         }

# BOOTSTRAP : si l’historique des tendances est vide/partiellement alloué, reconstruire à partir de données historiques         if ($historyData.Count -lt 2 -and ($leafName -match '^Aggregation_\d{8}' -or $leafName -eq 'Aggregation_Current')) {             Write-Host « Historique des tendances de démarrage à partir de données historiques... » -ForegroundColor Yellow             $dailyData = @{}                          # Source 1 : Csv de résumé dans le dossier actif (Aggregation_Current conserve tous les csv résumés)             $localSummaries = Get-ChildItem $OutputPath -Filter « SecureBoot_Summary_*.csv » -EA SilentlyContinue | nom de la Sort-Object             foreach ($summCsv dans $localSummaries) {                 try {                     $summ = Import-Csv $summCsv.FullName | Select-Object -First 1                     if ($summ. TotalDevices -and [int]$summ. TotalDevices -gt 0 -et $summ. ReportGeneratedAt) {                         $dateStr = ([datetime]$summ. ReportGeneratedAt). ToString(« aaaa-MM-jj »)                         $updated = if ($summ. Mise à jour) { [int]$summ. Mis à jour } else { 0 }                         $notUpd = if ($summ. NotUptodate) { [int]$summ. NotUptodate } else { [int]$summ. TotalDevices - $updated }                         $dailyData[$dateStr] = [PSCustomObject]@{                             Date = $dateStr ; Total = [int]$summ. TotalDevices ; Mis à jour = $updated ; NotUpdated = $notUpd                             NeedsReboot = 0 ; Erreurs = 0 ; ActionRequired = if ($summ. ActionRequired) { [int]$summ. ActionRequired } else { 0 }                         }                     }                 } catch { }             }                          # Source 2 : Anciens dossiers Aggregation_* horodatés (hérités, s’ils existent toujours)             $aggFolders = Get-ChildItem $parentDir -Directory -Filter « Aggregation_* » -EA SilentlyContinue |                 Where-Object { $_. Name -match '^Aggregation_\d{8}' } |                 nom de la Sort-Object             foreach ($folder dans $aggFolders) {                 $summCsv = Get-ChildItem $folder. FullName -Filter « SecureBoot_Summary_*.csv » -EA SilentlyContinue | Select-Object -First 1                 if ($summCsv) {                     try {                         $summ = Import-Csv $summCsv.FullName | Select-Object -First 1                         if ($summ. TotalDevices -and [int]$summ. TotalDevices -gt 0) {                             $dateStr = $folder. Name -replace '^Aggregation_(\d{4})(\d{2})(\d{2})_.*', '$1-$2-$3'                             $updated = if ($summ. Mise à jour) { [int]$summ. Mis à jour } else { 0 }                             $notUpd = if ($summ. NotUptodate) { [int]$summ. NotUptodate } else { [int]$summ. TotalDevices - $updated }                             $dailyData[$dateStr] = [PSCustomObject]@{                                 Date = $dateStr ; Total = [int]$summ. TotalDevices ; Mis à jour = $updated ; NotUpdated = $notUpd                                 NeedsReboot = 0 ; Erreurs = 0 ; ActionRequired = if ($summ. ActionRequired) { [int]$summ. ActionRequired } else { 0 }                             }                         }                     } catch { }                 }             }                          Source 3 : RolloutState.json WaveHistory (a des horodatages par onde à partir du jour 1)             # Cela fournit des points de données de base, même s’il n’existe aucun ancien dossier d’agrégation             $rolloutStatePaths = @(                 (Join-Path $parentDir « RolloutState\RolloutState.json »),                 (Join-Path $OutputPath « RolloutState\RolloutState.json »)             )             foreach ($rsPath dans $rolloutStatePaths) {                 if (Test-Path $rsPath) {                     try {                         $rsData = Get-Content $rsPath -Raw | ConvertFrom-Json                         if ($rsData.WaveHistory) {                             # Utiliser les dates de début des vagues comme points de données de tendance                             # Calculer les appareils cumulés ciblés à chaque vague                             $cumulativeTargeted = 0                             foreach ($wave dans $rsData.WaveHistory) {                                 if ($wave. StartedAt et $wave. DeviceCount) {                                     $waveDate = ([datetime]$wave. StartedAt). ToString(« aaaa-MM-jj »)                                     $cumulativeTargeted += [int]$wave. DeviceCount                                     if (-not $dailyData.ContainsKey($waveDate)) {                                         # Approximatif : à l’heure de début de la vague, seuls les appareils des vagues précédentes ont été mis à jour                                         $dailyData[$waveDate] = [PSCustomObject]@{                                             Date = $waveDate ; Total = $c.Total ; Mise à jour = [math] ::Max(0, $cumulativeTargeted - [int]$wave. DeviceCount)                                             NotUpdated = $c.Total - [math] ::Max(0, $cumulativeTargeted - [int]$wave. DeviceCount)                                             NeedsReboot = 0 ; Erreurs = 0 ; ActionRequired = 0                                         }                                     }                                 }                             }                         }                     } catch { }                     break # Utiliser d’abord trouvé                 }             }

if ($dailyData.Count -gt 0) {                 $historyData = @($dailyData.GetEnumerator() | clé Sort-Object | ForEach-Object { $_. Valeur })                 Write-Host « Bootstrapped $($historyData.Count) data points from historical summaries » -ForegroundColor Green             }         }

# Ajouter un point de données actuel (dédupliquer par jour - conserver la dernière date par jour)         $todayKey = (Get-Date). ToString(« aaaa-MM-jj »)         $existingToday = $historyData | Where-Object { "$($_. Date) » -like « $todayKey* » }         if ($existingToday) {             # Remplacer l’entrée du jour             $historyData = @($historyData | Where-Object { "$($_. Date) » -notlike « $todayKey* » })         }         $historyData += [PSCustomObject]@{             Date = $todayKey             Total = $c.Total             Updated = $c.Updated             NotUpdated = $stNotUptodate             NeedsReboot = $c.NeedsReboot             Errors = $c.WithErrors             ActionRequired = $c.ActionReq         }         # Supprimer les points de données incorrects (0 total) et conserver les 90 derniers         $historyData = @($historyData | Where-Object { [int]$_. Total -gt 0 })         # Aucune limite : les données de tendance sont d’environ 100 octets/entrée, une année entière = ~36 Ko         $historyData | ConvertTo-Json -Profondeur 3 | Set-Content $historyPath -Encodage UTF8         Write-Host " Trend history : $($historyData.Count) data points » -ForegroundColor DarkGray                  # Générer des données de graphique de tendance pour HTML         $trendLabels = ($historyData | ForEach-Object { "'$($_. Date)' » }) -join « , »         $trendUpdated = ($historyData | ForEach-Object { $_. Mise à jour }) -join « , »         $trendNotUpdated = ($historyData | ForEach-Object { $_. NotUpdated }) -join « , »         $trendTotal = ($historyData | ForEach-Object { $_. Total }) -join « , »         # Projection : étendre la courbe de tendance à l’aide d’un doublement exponentiel (2,4,8,16...)         # Dérive la taille des vagues et la période d’observation à partir de données d’historique des tendances réelles.Nombre - Taille de la vague = augmentation la plus importante sur une seule période observée dans l’historique (la vague la plus récente déployée)         Nombre - Jours d’observation = jours calendaires moyens entre les points de données de tendance (fréquence d’exécution)         # Double ensuite la taille de l’onde chaque période, correspondant à la stratégie de croissance 2x de l’orchestrateur.$projLabels = «  » ; $projUpdated = «  » ; $projNotUpdated = «  » ; $hasProjection = $false         if ($historyData.Count -ge 2) {             $lastUpdated = $c.Updated             $remaining = $stNotUptodate # Uniquement les appareils SB-ON non mis à jour (à l’exclusion de SecureBoot OFF)             $projDates = @() ; $projValues = @() ; $projNotUpdValues = @()             $projDate = Get-Date

# Dériver la taille des ondes et la période d’observation à partir de l’historique des tendances             $increments = @()             $dayGaps = @()             for ($hi = 1 ; $hi -lt $historyData.Count ; $hi++) {                 $inc = $historyData[$hi]. Mise à jour : $historyData[$hi-1]. Actualisé                 if ($inc -gt 0) { $increments += $inc }                 try {                     $d 1 = [datetime] ::P arse($historyData[$hi-1]. Date)                     $d 2 = [datetime] ::P arse($historyData[$hi]. Date)                     $gap = ($d 2 - $d 1). TotalDays                     if ($gap -gt 0) { $dayGaps += $gap }                 } catch {}             }             # Taille de l’onde = incrément positif le plus récent (onde actuelle), secours à la moyenne, minimum 2             $waveSize = if ($increments. Count -gt 0) {                 [math] ::Max(2, $increments[-1])             } else { 2 }             # Période d’observation = écart moyen entre les points de données (jours calendaires par vague), minimum 1             $waveDays = if ($dayGaps.Count -gt 0) {                 [math] ::Max(1, [math] ::Round(($dayGaps | Measure-Object -Average). Moyenne, 0))             } else { 1 }

            Write-Host " Projection : waveSize=$waveSize (à partir du dernier incrément), waveDays=$waveDays (avg gap from history) » -ForegroundColor DarkGray

$dayCounter = 0             Nombre de projets jusqu’à ce que tous les appareils soient mis à jour ou 365 jours maximum             for ($pi = 1 ; $pi -le 365 ; $pi++) {                 $projDate = $projDate.AddDays(1)                 $dayCounter++                 # À chaque limite de période d’observation, déployez une onde puis doublez                 if ($dayCounter -ge $waveDays) {                     $devicesThisWave = [math] ::Min($waveSize, $remaining)                     $lastUpdated += $devicesThisWave                     $remaining -= $devicesThisWave                     if ($lastUpdated -gt ($c.Updated + $stNotUptodate)) { $lastUpdated = $c.Updated + $stNotUptodate ; $remaining = 0 }                     # Taille d’onde double pour la période suivante (stratégie d’orchestrateur 2x)                     $waveSize = $waveSize * 2                     $dayCounter = 0                 }                 $projDates += « '$($projDate.ToString(« aaaa-MM-dd »)' »                 $projValues += $lastUpdated                 $projNotUpdValues += [math] ::Max(0, $remaining)                 if ($remaining -le 0) { break }             }             $projLabels = $projDates -join « , »             $projUpdated = $projValues -join « , »             $projNotUpdated = $projNotUpdValues -join « , »             $hasProjection = $projDates.Count -gt 0         } elseif ($historyData.Count -eq 1) {             Write-Host « Projection : besoin d’au moins 2 points de données de tendance pour dériver le minutage des vagues » -ForegroundColor DarkGray         }         # Générer des chaînes de données de graphique combinées pour la chaîne here-string         $allChartLabels = if ($hasProjection) { « $trendLabels,$projLabels » } else { $trendLabels }         $projDataJS = if ($hasProjection) { $projUpdated } else { «  » }         $projNotUpdJS = if ($hasProjection) { $projNotUpdated } else { «  » }         $histCount = ($historyData | Measure-Object). Compter         $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Décroissant | ForEach-Object {             @{ name=$_. Clé; total=$_. Value.Total ; updated=$_. Value.Updated ; highConf=$_. Value.HighConf ; actionReq=$_. Value.ActionReq }         } | ConvertTo-Json -Profondeur 3 | Set-Content (Join-Path $dataDir « manufacturers.json ») - Encodage UTF8         # Convertir des fichiers de données JSON au format CSV pour les téléchargements Excel lisibles par l’utilisateur         Write-Host « Conversion des données d’appareil en CSV pour le téléchargement d’Excel... » -ForegroundColor Gray         foreach ($dfName dans $stDeviceFiles) {             $jsonFile = Join-Path $dataDir « $dfName.json »             $csvFile = Join-Path $OutputPath « SecureBoot_${dfName}_$timestamp.csv »             if (Test-Path $jsonFile) {                 try {                     $jsonData = Get-Content $jsonFile -Raw | ConvertFrom-Json                     if ($jsonData.Count -gt 0) {                         # Inclure des colonnes supplémentaires pour update_pending CSV                         $selectProps = if ($dfName -eq « update_pending ») {                             @('HostName', 'WMI_Manufacturer', 'WMI_Model', 'BucketId', 'ConfidenceLevel', 'IsUpdated', 'UEFICA2023Status', 'UEFICA2023Error', 'AvailableUpdatesPolicy', 'WinCSKeyApplied', 'SecureBootTaskStatus')                         } else {                             @('HostName', 'WMI_Manufacturer', 'WMI_Model', 'BucketId', 'ConfidenceLevel', 'IsUpdated', 'UEFICA2023Error', 'SecureBootTaskStatus', 'KnownIssueId', 'SkipReasonKnownIssue')                         }                         $jsonData | Select-Object $selectProps |                             Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8                         Write-Host " $dfName -> $($jsonData.Count) rows -> CSV » -ForegroundColor DarkGray                     }                 } catch { Write-Host " $dfName - ignoré » -ForegroundColor DarkYellow }             }         }         # Générer un tableau de bord HTML autonome         $htmlPath = Join-Path $OutputPath « SecureBoot_Dashboard_$timestamp.html »         Write-Host « Génération d’un tableau de bord HTML autonome... » -ForegroundColor Yellow         # PROJECTION DE VITESSE : calculer à partir de l’historique d’analyse ou du résumé précédent         $stDeadline = [datetime]"2026-06-24 » # KEK cert expiry         $stDaysToDeadline = [math] ::Max(0, ($stDeadline - (Get-Date)). Jours)         $stDevicesPerDay = 0         $stProjectedDate = $null         $stVelocitySource = « N/A »         $stWorkingDays = 0         $stCalendarDays = 0         # Essayez d’abord l’historique des tendances (léger, déjà géré par l’agrégateur — remplace les ScanHistory.json gonflés)         if ($historyData.Count -ge 2) {             $validHistory = @($historyData | Where-Object { [int]$_. Total -gt 0 -and [int]$_. Mise à jour de -ge 0 })             if ($validHistory.Count -ge 2) {                 $prev = $validHistory[-2] ; $curr = $validHistory[-1]                 $prevDate = [datetime] ::P arse($prev. Date.Substring(0, [Math] ::Min(10, $prev. Date.Length)))                 $currDate = [datetime] ::P arse($curr. Date.Substring(0, [Math] ::Min(10, $curr. Date.Length)))                 $daysDiff = ($currDate - $prevDate). TotalDays                 if ($daysDiff -gt 0) {                     $updDiff = [int]$curr. Mise à jour : [int]$prev. Actualisé                     if ($updDiff -gt 0) {                         $stDevicesPerDay = [math] ::Round($updDiff / $daysDiff, 0)                         $stVelocitySource = « TrendHistory »                     }                 }             }         }         # Essayer le résumé du déploiement de l’orchestrateur (a une vitesse précalcalée)         if ($stVelocitySource -eq « N/A » -and $RolloutSummaryPath -and (Test-Path $RolloutSummaryPath)) {             try {                 $rolloutSummary = Get-Content $RolloutSummaryPath -Raw | ConvertFrom-Json                 if ($rolloutSummary.DevicesPerDay -and [double]$rolloutSummary.DevicesPerDay -gt 0) {                     $stDevicesPerDay = [math] ::Round([double]$rolloutSummary.DevicesPerDay, 1)                     $stVelocitySource = « Orchestrator »                     if ($rolloutSummary.ProjectedCompletionDate) {                         $stProjectedDate = $rolloutSummary.ProjectedCompletionDate                     }                     if ($rolloutSummary.WorkingDaysRemaining) { $stWorkingDays = [int]$rolloutSummary.WorkingDaysRemaining }                     if ($rolloutSummary.CalendarDaysRemaining) { $stCalendarDays = [int]$rolloutSummary.CalendarDaysRemaining }                 }             } catch { }         }         # Secours : essayez le csv récapitulative précédent (recherche dans le dossier actif ET dossiers d’agrégation parent/frère)         if ($stVelocitySource -eq « N/A ») {             $searchPaths = @(                 (Join-Path $OutputPath « SecureBoot_Summary_*.csv »)             )             # Rechercher également des dossiers d’agrégation frères (l’orchestrateur crée un dossier à chaque exécution)             $parentPath = Split-Path $OutputPath -Parent             if ($parentPath) {                 $searchPaths += (Join-Path $parentPath « Aggregation_*\SecureBoot_Summary_*.csv »)                 $searchPaths += (Join-Path $parentPath « SecureBoot_Summary_*.csv »)             }             $prevSummary = $searchPaths | ForEach-Object { Get-ChildItem $_ -EA SilentlyContinue } | Sort-Object LastWriteTime - Décroissant | Select-Object -First 1             if ($prevSummary) {                 try {                     $prevStats = Get-Content $prevSummary.FullName | ConvertFrom-CSV                     $prevDate = [datetime]$prevStats.ReportGeneratedAt                     $daysSinceLast = ((Get-Date) - $prevDate). TotalDays                     if ($daysSinceLast -gt 0.01) {                         $prevUpdated = [int]$prevStats.Updated                         $updDelta = $c.Updated - $prevUpdated                         if ($updDelta -gt 0) {                             $stDevicesPerDay = [math] ::Round($updDelta / $daysSinceLast, 0)                             $stVelocitySource = « PreviousReport »                         }                     }                 } catch { }             }         }         # Secours : calculer la vitesse à partir de l’étendue complète de l’historique des tendances (premier et dernier point de données)         if ($stVelocitySource -eq « N/A » -and $historyData.Count -ge 2) {             $validHistory = @($historyData | Where-Object { [int]$_. Total -gt 0 -and [int]$_. Mise à jour de -ge 0 })             if ($validHistory.Count -ge 2) {                 $first = $validHistory[0]                 $last = $validHistory[-1]                 $firstDate = [datetime] ::P arse($first. Date.Substring(0, [Math] :Min(10, $first. Date.Length)))                 $lastDate = [datetime] ::P arse($last. Date.Substring(0, [Math] ::Min(10, $last. Date.Length)))                 $daysDiff = ($lastDate - $firstDate). TotalDays                 if ($daysDiff -gt 0) {                     $updDiff = [int]$last. Mise à jour : [int]$first. Actualisé                     if ($updDiff -gt 0) {                         $stDevicesPerDay = [math] ::Round($updDiff / $daysDiff, 1)                         $stVelocitySource = « TrendHistory »                     }                 }             }         }         # Calculer la projection à l’aide d’un doublement exponentiel (cohérent avec le graphique de tendance)         # Réutiliser les données de projection déjà calculées pour le graphique, le cas échéant         if ($hasProjection -and $projDates.Count -gt 0) {             # Utiliser la dernière date projetée (lorsque tous les appareils sont mis à jour)             $lastProjDateStr = $projDates[-1] -replace « ' », «  »             $stProjectedDate = ([datetime] ::P arse($lastProjDateStr)). ToString(« MMM dd, aaaa »)             $stCalendarDays = ([datetime] ::P arse($lastProjDateStr) - (Get-Date)). Jours             $stWorkingDays = 0             $d = Get-Date             for ($i = 0 ; $i -lt $stCalendarDays ; $i++) {                 $d = $d.AddDays(1)                 if ($d.DayOfWeek -ne 'Saturday' -and $d.DayOfWeek -ne 'Sunday') { $stWorkingDays++ }             }         } elseif ($stDevicesPerDay -gt 0 -and $stNotUptodate -gt 0) {             # Secours : projection linéaire si aucune donnée exponentielle n’est disponible             $daysNeeded = [math] ::Ceiling($stNotUptodate / $stDevicesPerDay)             $stProjectedDate = (Get-Date). AddDays($daysNeeded). ToString(« MMM dd, aaaa »)             $stWorkingDays = 0 ; $stCalendarDays = $daysNeeded             $d = Get-Date             for ($i = 0 ; $i -lt $daysNeeded ; $i++) {                 $d = $d.AddDays(1)                 if ($d.DayOfWeek -ne 'Saturday' -and $d.DayOfWeek -ne 'Sunday') { $stWorkingDays++ }             }         }         Nombre de fichiers HTML de vitesse de génération         $velocityHtml = if ($stDevicesPerDay -gt 0) {             « <div><forte>&#128640 ; Devices/Day :</strong> $($stDevicesPerDay.ToString('N0')) (source : $stVelocitySource)</div>" +             « <div><forte>&#128197 ; Achèvement projeté :</strong> $stProjectedDate" +             $(if ($stProjectedDate -and [datetime] ::P arse($stProjectedDate) -gt $stDeadline) { " <span style='color :#dc3545 ; font-weight :bold'>&#9888; PAST DEADLINE</span> » } else { " <span style='color :#28a745'>&#10003; Before deadline</span> » }) +             « </div> » +             « <div><forte>&#128336 ; Jours ouvrés :</strong> $stWorkingDays | <jours>Calendar forts :</strong> $stCalendarDays</div>" +             "<div style='font-size :.8em ; color :#888'>Échéance : 24 juin 2026 (expiration du certificat KEK) | Jours restants : $stDaysToDeadline</div> »         } else {             "<div style='padding :8px ; background :#fff3cd ; border-radius :4px ; border-left :3px solid #ffc107'>" +             « <forte>&#128197 ; Achèvement projeté : </strong> Données insuffisantes pour le calcul de la vitesse.                                                                                  " +             « Exécuter l’agrégation au moins deux fois avec des modifications de données pour établir un taux.<br/> » +             « <échéance>forte : </strong> 24 juin 2026 (expiration du certificat KEK) | <jours>forts restants : </strong> $stDaysToDeadline</div> »         }         Compte à rebours de l’expiration du certificat         $certToday = Get-Date         $certKekExpiry = [datetime]"2026-06-24 »         $certUefiExpiry = [datetime]"27-06-2026 »         $certPcaExpiry = [datetime]"2026-10-19 »         $daysToKek = [math] ::Max(0, ($certKekExpiry - $certToday). Jours)         $daysToUefi = [math] ::Max(0, ($certUefiExpiry - $certToday). Jours)         $daysToPca = [math] ::Max(0, ($certPcaExpiry - $certToday). Jours)         $certUrgency = if ($daysToKek -lt 30) { '#dc3545' } elseif ($daysToKek -lt 90) { '#fd7e14' } else { '#28a745' }         # Helper : Lire les enregistrements à partir de JSON, résumé du compartiment de génération + N premières lignes d’appareil         $maxInlineRows = 200         function Build-InlineTable {             param([string]$JsonPath, [int]$MaxRows = 200, [string]$CsvFileName = «  »)             $bucketSummary = «  »             $deviceRows = «  »             $totalCount = 0             if (Test-Path $JsonPath) {                 try {                     $data = Get-Content $JsonPath -Raw | ConvertFrom-Json                     $totalCount = $data. Compter                     # RÉSUMÉ DU COMPARTIMENT : Regrouper par BucketId, afficher les nombres par compartiment avec Mise à jour à partir des statistiques globales du compartiment                     if ($totalCount -gt 0) {                         $buckets = $data | Group-Object BucketId | nombre de Sort-Object -Décroissant                         $bucketSummary = "><2 h3 style='font-size :.95em ; color :#333 ; margin :10px 0 5px'><3 By Hardware Bucket ($($buckets. Count) buckets)><4 /h3> »                         $bucketSummary += "><6 div style='max-height :300px ; overflow-y :auto ; margin-bottom :15px'><table><thead><tr><th><5 BucketID><6 /th><th style='text-align :right'>Total</th><th style='text-align :right ; color :#28a745'>mise à jour</th><th style='text-align :right ; color :#dc3545'>Not Updated</th><th><1 Manufacturer><2 /th></tr></thead><tbody>"                         foreach ($b dans $buckets) {                             $bid = if ($b.Name) { $b.Name } else { « (empty) » }                             $mfr = ($b.Group | Select-Object -First 1). WMI_Manufacturer                             # Obtenir le nombre de mises à jour à partir des statistiques globales du compartiment (tous les appareils de ce compartiment sur l’ensemble du jeu de données)                             $lookupKey = $bid                             $globalBucket = if ($stAllBuckets.ContainsKey($lookupKey)) { $stAllBuckets[$lookupKey] } else { $null }                             $bUpdatedGlobal = if ($globalBucket) { $globalBucket.Updated } else { 0 }                             $bTotalGlobal = if ($globalBucket) { $globalBucket.Count } else { $b.Count }                             $bNotUpdatedGlobal = $bTotalGlobal - $bUpdatedGlobal                             $bucketSummary += "<tr><td style='font-size :.8em'>$bid><4 /td><td style='text-align :right ; font-weight :bold'>$bTotalGlobal><8 /td><td style='text-align :right ; color :#28a745 ; font-weight :bold'>$bUpdatedGlobal><2 /td><td style='text-align :right ; color :#dc3545 ; font-weight :bold'>$bNotUpdatedGlobal><6 /td><td><9 $mfr</td></tr>'n"                         }                         $bucketSummary += « </tbody></table></div> »                     }                     # DÉTAILS DE L’APPAREIL : N premières lignes en tant que liste plate                     $slice = $data | Select-Object -Premier $MaxRows                     foreach ($d dans $slice) {                         $conf = $d.ConfidenceLevel                         $confBadge = if ($conf -match « High ») { '<span class="badge badge-success">High Conf><2 /span>' }                                      elseif ($conf -match « Not Sup ») { '<span class="badge badge-danger">Non pris en charge><6 /span>' }                                      elseif ($conf -match « Under ») { '<span class="badge badge-info">Under Obs><0 /span>' }                                      elseif ($conf -match « Paused ») { '<span class="badge badge-warning">Paused><4 /span>' }                                      else { '<span class="badge badge-warning">Action Req><8 /span>' }                         $statusBadge = if ($d.IsUpdated) { '><00 span class="badge badge-success"><01 Updated</span>' }                                        elseif ($d.UEFICA2023Error) { '><04 span class="badge badge-danger"><05 Error</span>' }                                        else { '><08 span class="badge badge-warning"><09 En attente><0 /span>' }                         $deviceRows += "><12 tr><td><5 $($d.HostName)><16 /td><td><9 $($d.WMI_Manufacturer)><20 /td><td><3 $($d.WMI_Model)><24 /td><td><7 $confBadge><8 /td><td><1 $statusBadge><2 // td><td><5 $(if($d.UEFICA2023Error){$d.UEFICA2023Error}else{'-'})><36 /td><td style='font-size :.75em'><39 $($d.BucketId)><40 /td></tr><3 'n'                     }                 } catch { }             }             if ($totalCount -eq 0) {                 return "><44 div style='padding :20px ; color :#888 ; font-style :italic'><45 Aucun appareil de cette catégorie.><46 /div> »             }             $showing = [math] ::Min($MaxRows, $totalCount)             $header = "><48 div style='margin :5px 0 ; font-size :.85em ; color :#666'><49 Total : $($totalCount.ToString(« N0 »)) devices »             if ($CsvFileName) { $header += " | ><50 un href='$CsvFileName' style='color :#1a237e ; font-weight :bold'>&#128196 ; Télécharger le fichier CSV complet pour Excel><3 /a>" }             $header += « ><55 /div> »             $deviceHeader = "><57 h3 style='font-size :.95em ; color :#333 ; margin :10px 0 5px'><58 Détails de l’appareil (montrant la première $showing)><59 /h3>"             $deviceTable = "><61 div style='max-height :500px ; overflow-y :auto'><table><thead><tr><th><0 HostName><1 /th><th><4 Manufacturer><5 /th><th><8 Model><9 /th><th><2 Confidence><3 /th><th><6 Status><7 /th><th><0 Error><1 /th><th><4 BucketId><5 /th></tr></thead><tbody><2 $deviceRows><3 /tbody></table></div> »             return « $header$bucketSummary$deviceHeader$deviceTable »         }         # Générer des tables inline à partir des fichiers JSON déjà sur le disque, en les liant à des csv         $tblErrors = Build-InlineTable -JsonPath (Join-Path $dataDir « errors.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_errors_$timestamp.csv »         $tblKI = Build-InlineTable -JsonPath (Join-Path $dataDir « known_issues.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_known_issues_$timestamp.csv »         $tblKEK = Build-InlineTable -JsonPath (Join-Path $dataDir « missing_kek.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_missing_kek_$timestamp.csv »         $tblNotUpd = Build-InlineTable -JsonPath (Join-Path $dataDir « not_updated.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_not_updated_$timestamp.csv »         $tblTaskDis = Build-InlineTable -JsonPath (Join-Path $dataDir « task_disabled.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_task_disabled_$timestamp.csv »         $tblTemp = Build-InlineTable -JsonPath (Join-Path $dataDir « temp_failures.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_temp_failures_$timestamp.csv »         $tblPerm = Build-InlineTable -JsonPath (Join-Path $dataDir « perm_failures.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_perm_failures_$timestamp.csv »         $tblUpdated = Build-InlineTable -JsonPath (Join-Path $dataDir « updated_devices.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_updated_devices_$timestamp.csv »         $tblActionReq = Build-InlineTable -JsonPath (Join-Path $dataDir « action_required.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_action_required_$timestamp.csv »         $tblUnderObs = Build-InlineTable -JsonPath (Join-Path $dataDir « under_observation.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_under_observation_$timestamp.csv »         $tblNeedsReboot = Build-InlineTable -JsonPath (Join-Path $dataDir « needs_reboot.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_needs_reboot_$timestamp.csv »         $tblSBOff = Build-InlineTable -JsonPath (Join-Path $dataDir « secureboot_off.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_secureboot_off_$timestamp.csv »         $tblRolloutIP = Build-InlineTable -JsonPath (Join-Path $dataDir « rollout_inprogress.json ») -MaxRows $maxInlineRows -CsvFileName « SecureBoot_rollout_inprogress_$timestamp.csv »         # Table personnalisée pour la mise à jour en attente : inclut les colonnes UEFICA2023Status et UEFICA2023Error         $tblUpdatePending = «  »         $upJsonPath = Join-Path $dataDir « update_pending.json »         if (test-Path $upJsonPath) {             try {                 $upData = Get-Content $upJsonPath -Raw | ConvertFrom-Json                 $upCount = $upData.Count                 if ($upCount -gt 0) {                     $upHeader = "<div style='margin :5px 0 ; font-size :.85em ; color :#666'>Total : $($upCount.ToString(« N0 »)) appareils | <un href='SecureBoot_update_pending_$timestamp.csv' style='color :#1a237e ; font-weight :bold'>&#128196 ; Télécharger le fichier CSV complet pour Excel><4 /a></div> »                     $upRows = «  »                     $upSlice = $upData | Select-Object -Premier $maxInlineRows                     foreach ($d dans $upSlice) {                         $uefiSt = if ($d.UEFICA2023Status) { $d.UEFICA2023Status } else { '<span style="color :#999">null><0 /span>' }                         $uefiErr = if ($d.UEFICA2023Error) { « <span style='color :#dc3545'>$($d.UEFICA2023Error)</span> » } else { '-' }                         $policyVal = if ($d.AvailableUpdatesPolicy) { $d.AvailableUpdatesPolicy } else { '-' }                         $wincsVal = if ($d.WinCSKeyApplied) { '<span class="badge badge-success">Oui><8 /span>' } else { '-' }                         $upRows += « <tr><td><3 $($d.HostName)</td><td><7 $($d.WMI_Manufacturer)</td><td><1 $($d.WMI_Model)</td><</td><td><5 $uefiSt><6 /td><td><9 $uefiErr><50 /td><td><53 $policyVal><54 /td><td><57 $wincsVal><58 /td><td style='font-size :.75em'>$($d.BucketId)</td></tr><65 'n »                     }                     $upShowing = [math] ::Min($maxInlineRows, $upCount)                     $upDevHeader = "<h3 style='font-size :.95em ; color :#333 ; margin :10px 0 5px'>Détails de l’appareil (montrant la première $upShowing)</h3>"                     $upTable = "<div style='max-height :500px ; overflow-y :auto'><table><thead><tr><th><9 HostName><0 /th><th><3 Manufacturer><4 /th><th><7 Model><8 /th><th><1 UEFICA2023Status><2 /th><th><5 UEFICA 2023Error><6 /th><th><9 Policy</th><th>WinCS Key</th><th>>BucketId</th></tr></thead><tbody><5 $upRows><6 /tbody></table></div> »                     $tblUpdatePending = « $upHeader$upDevHeader$upTable »                 } else {                     $tblUpdatePending = "<div style='padding :20px ; color :#888 ; font-style :italic'>Aucun appareil de cette catégorie.</div> »                 }             } catch {                 $tblUpdatePending = "<div style='padding :20px ; color :#888 ; font-style :italic'>Aucun appareil de cette catégorie.</div> »             }         } else {             $tblUpdatePending = "<div style='padding :20px ; color :#888 ; font-style :italic'>Aucun appareil de cette catégorie.</div> »         }         Compte à rebours de l’expiration du certificat         $certToday = Get-Date         $certKekExpiry = [datetime]"2026-06-24 »         $certUefiExpiry = [datetime]"27-06-2026 »         $certPcaExpiry = [datetime]"2026-10-19 »         $daysToKek = [math] ::Max(0, ($certKekExpiry - $certToday). Jours)         $daysToUefi = [math] ::Max(0, ($certUefiExpiry - $certToday). Jours)         $daysToPca = [math] ::Max(0, ($certPcaExpiry - $certToday). Jours)         $certUrgency = if ($daysToKek -lt 30) { '#dc3545' } elseif ($daysToKek -lt 90) { '#fd7e14' } else { '#28a745' }         # Générer des données de graphique de fabricant inline (10 premiers par nombre d’appareils)         $mfrSorted = $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Décroissant | Select-Object -10 premiers         $mfrChartTitle = if ($stMfrCounts.Count -le 10) { « By Manufacturer » } else { « Top 10 Manufacturers » }         $mfrLabels = ($mfrSorted | ForEach-Object { "'$($_. Clé)' » }) -join « , »         $mfrUpdated = ($mfrSorted | ForEach-Object { $_. Value.Updated }) -join « , »         $mfrUpdatePending = ($mfrSorted | ForEach-Object { $_. Value.UpdatePending }) -join « , »         $mfrHighConf = ($mfrSorted | ForEach-Object { $_. Value.HighConf }) -join « , »         $mfrUnderObs = ($mfrSorted | ForEach-Object { $_. Value.UnderObs }) -join « , »         $mfrActionReq = ($mfrSorted | ForEach-Object { $_. Value.ActionReq }) -join « , »         $mfrTempPaused = ($mfrSorted | ForEach-Object { $_. Value.TempPaused }) -join « , »         $mfrNotSupported = ($mfrSorted | ForEach-Object { $_. Value.NotSupported }) -join « , »         $mfrSBOff = ($mfrSorted | ForEach-Object { $_. Value.SBOff }) -join « , »         $mfrWithErrors = ($mfrSorted | ForEach-Object { $_. Value.WithErrors }) -join « , »         # Table du fabricant de build         $mfrTableRows = «  »         $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Décroissant | ForEach-Object {             $mfrTableRows += "<tr><td><7 $($_. Key)</td><td>$($_. Value.Total.ToString(« N0 »))</td><td>$($_. Value.Updated.ToString(« N0 »))</td><td>$($_. Value.HighConf.ToString(« N0 »))><0 /td><td>$($_. Value.ActionReq.ToString(« N0 »))><4 /td></tr>'n »         }         $htmlContent = @" < ! DOCTYPE html> <html lang="en"> ><3 de tête de < <meta charset="UTF-8"> <meta name="viewport » content="width=device-width, initial-scale=1.0"> <titre><9 tableau de bord de l’état du certificat de démarrage sécurisé><0 /title><1 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script><5 ><7 de style < *{box-sizing :border-box ; margin :0 ; remplissage :0} body{font-family :'Segoe UI',Tahoma,sans-serif ; background :#f0f2f5 ; color :#333} .header{background :linear-gradient(135deg,#1a237e,#0d47a1) ; color :#fff ; remplissage :20px 30px} .header h1{font-size :1.6em ; margin-bottom :5px} .header .meta{font-size :.85em ; opacity :.9} .container{max-width :1400px ; margin :0 auto ; remplissage :20px} .cards{display :grid ; grid-template-columns :repeat(auto-fill,minmax(170px,1fr)) ; gap :12px ; margin :20px 0} . carte{background :#fff ; border-radius :10px ; remplissage :15px ; box-shadow :0 2px 8px rgba(0,0,0,08) ; border-left :4px solid #ccc ;transition :transform .2s} . carte :hover{transform :translateY(-2px) ; box-shadow :0 4px 15px rgba(0,0,0,.12)} . carte .value{font-size :1.8em ; font-weight :700} . carte .label{font-size :.8em ; color :#666 ; margin-top :4px} . carte .pct{font-size :.75em ; color :#888} .section{background :#fff ; border-radius :10px ; remplissage :20px ; margin :15px 0 ; box-shadow :0 2px 8px rgba(0,0,0,08)} .section h2{font-size :1.2em ; color :#1a237e ; margin-bottom :10px ; cursor :pointer ; user-select :none} .section h2 :hover{text-decoration :underline} .section-body{display :none} .section-body.open{display :block} .charts{display :grid ; grid-template-columns :1fr 1fr ; gap :20px ; margin :20px 0} .chart-box{background :#fff ; border-radius :10px ; remplissage :20px ; box-shadow :0 2px 8px rgba(0,0,0,08)} table{width :100 %; border-collapse :collapse ; font-size :.85em} th{background :#e8eaf6 ; remplissage :8px 10px ; text-align :left ; position :collante ; top :0 ; z-index :1} td{padding :6px 10px ; border-bottom :1px solid #eee} tr :hover{background :#f5f5f5} .badge{display :inline-block ; remplissage :2px 8px ;border-radius :10px ; font-size :.75em ; font-weight :700} .badge-success{background :#d4edda ; color :#155724} .badge-danger{background :#f8d7da ; color :#721c24} .badge-warning{background :#fff3cd ; color :#856404} .badge-info{background :#d1ecf1 ; color :#0c5460} .top-link{float :right ; font-size :.8em ; color :#1a237e ; text-decoration :none} .footer{text-align :center ; remplissage :20px ; color :#999 ; font-size :.8em} a{color :#1a237e} ><9 /style < <>/head >du corps < <div class="header">     <tableau de bord d’état du certificat de démarrage sécurisé h1></h1>     <div class="meta">Généré : $($stats. ReportGeneratedAt) | Nombre total d’appareils : $($c.Total.ToString(« N0 »)) | Compartiments uniques : $($stAllBuckets.Count)</div><3 <><5 /div <div class="container">                                                                                                         

<!-- les cartes d’indicateur de performance clé (KPI Cards) - cliquables, liées à des sections --> <div class="cards">     <a class="carte » href="#s-nu » onclick="openSection('d-nu') » style="border-left-color :#dc3545 ; text-decoration :none ; position :relative"><div style="position :absolute ; top :8px ; right :8px ; background :#dc3545 ; color :#fff ; remplissage :1px 6px ; border-radius :8px ; font-size :.65em ; font-weight :700">PRIMARY</div><div class="value » style="color :#dc3545">$($stNotUptodate.ToString(« ( » N0 »))</div><div class="label">NOT UPDATED><6 /div><div class="pct">$($stats. PercentNotUptodate) % - ACTION REQUISE><0 /div></a><3     <a class="carte » href="#s-upd » onclick="openSection('d-upd') » style="border-left-color :#28a745 ; text-decoration :none ; position :relative"><div style="position :absolute ; top :8px ; right :8px ; background :#28a745 ; color :#fff ; remplissage :1px 6px ; border-radius :8px ; font-size :.65em ; font-weight :700">PRIMARY><8 /div><div class="value » style="color :#28a745">$($c.Updated.ToString(« ) N0 »))</div><div class="label">mise à jour><6 /div><div class="pct">$($stats. PercentCertUpdated) %</div></a><3     <a class="carte » href="#s-sboff » onclick="openSection('d-sboff') » style="border-left-color :#6c757d ; text-decoration :none ; position :relative"><div style="position :absolute ; top :8px ; right :8px ; background :#6c757d ; color :#fff ; remplissage :1px 6px ; border-radius :8px ; font-size :.65em ; font-weight :700">PRIMARY><8 /div><div class="value"><1 $($c.SBOff.ToString(« N0 »))><2 /div><div class="label"><5 SecureBoot OFF</div><div class="pct"><9 $(if($c.Total -gt 0){[math] ::Round(($c.SBOff/$c.Total)*100,1)}else{0}) % - Out of Scope><0 /div></a><3     <a class="carte » href="#s-nrb » onclick="openSection('d-nrb') » style="border-left-color :#ffc107 ; text-decoration :none"><div class="value » style="color :#ffc107">$($c.NeedsReboot.ToString(« N0 »))</div><div class="label">nécessite un redémarrage><2 /div><classe div ="pct">$(if($c.Total -gt 0){[math] ::Round(($c.NeedsReboot/$c.Total)*100,1)}else{0}) % - en attente de redémarrage><6 /div></a><9     <a class="carte » href="#s-upd-pend » onclick="openSection('d-upd-pend') » style="border-left-color :#6f42c1 ; text-decoration :none"><div class="value » style="color :#6f42c1">$($c.UpdatePending.ToString(« N0 »))</div><div class="label">Update Pending</div><div class="pct">$(if($c.Total -gt 0){[math] ::Round(($c.UpdatePending/$c.Total)*100,1)}else{0}) % - Policy/WinCS applied, en attente de mise à jour><2 /div></a><5     <a class="carte » href="#s-rip » onclick="openSection('d-rip') » style="border-left-color :#17a2b8 ; text-decoration :none"><div class="value">$($c.RolloutInProgress)</div><div class="label">Rollout In Progress><4 /div><div class="pct">$(if($c.Total -gt 0){[math] ::Round(($c.RolloutInProgress/$c.Total)*100,1)}else{0}) %</div></a><11     <a class="carte » href="#s-nu » onclick="openSection('d-nu') » style="border-left-color :#28a745 ; text-decoration :none"><div class="value » style="color :#28a745">$($c.HighConf.ToString(« N0 »))</div><div class="label">High Confidence><20 /div><div class="pct">$($stats. PercentHighConfidence) % :><24 /div></a><27     <a class="carte » href="#s-uo » onclick="openSection('d-uo') » style="border-left-color :#17a2b8 ; text-decoration :none"><div class="value » style="color :#ffc107"><1 $($c.UnderObs.ToString(« N0 »))><2 /div><div class="label"><5 Under Observation><36 /div><div class="pct"><9 $(if($c.Total -gt 0){[math] ::Round(($c.UnderObs/$c.Total)*100,1)}else{0}) %</div></a><3     <a class="carte » href="#s-ar » onclick="openSection('d-ar') » style="border-left-color :#fd7e14 ; text-decoration :none"><div class="value » style="color :#fd7e14">$($c.ActionReq.ToString(« N0 »))</div><div class="label">Action Obligatoire><2 /div><div class="pct">$($stats. PercentActionRequired) % : doit tester><6 /div></a><9     <a class="carte » href="#s-err » onclick="openSection('d-err') » style="border-left-color :#dc3545 ; text-decoration :none"><div class="value » style="color :#dc3545">$($stAtRisk.ToString(« N0 »))</div><div class="label">At Risk><68 /div><div class="pct">$($stats. PercentAtRisk) % : similaire à échec><2 /div></a><5     <a class="carte » href="#s-td » onclick="openSection('d-td') » style="border-left-color :#dc3545 ; text-decoration :none"><div class="value » style="color :#dc3545">$($c.TaskDisabled.ToString(« N0 »))</div><div class="label">Task Disabled><4 /div><classe div ="pct">$(if($c.Total -gt 0){[math] ::Round(($c.TaskDisabled/$c.Total)*100,1)}else{0}) % - Bloqué><8 /div></a><91     <a class="carte » href="#s-tf » onclick="openSection('d-tf') » style="border-left-color :#fd7e14 ; text-decoration :none"><div class="value » style="color :#fd7e14">$($c.TempPaused.ToString(« N0 »))</div><div class="label">Temp. Suspendu</div><div class="pct">$(if($c.Total -gt 0){[math] ::Round(($c.TempPaused/$c.Total)*100,1)}else{0}) %</div></a>     <a class="carte » href="#s-ki » onclick="openSection('d-ki') » style="border-left-color :#dc3545 ; text-decoration :none"><div class="value » style="color :#dc3545">$($c.WithKnownIssues.ToString(« N0 »))</div><div class="label">Problèmes connus><6 /div><div class="pct">$(if($c.Total -gt 0){[math] ::Round(($c.WithKnownIssues/$c.Total)*100,1)}else{0}) %</div></a><3     <a class="carte » href="#s-kek » onclick="openSection('d-kek') » style="border-left-color :#fd7e14 ; text-decoration :none"><div class="value » style="color :#fd7e14">$($c.WithMissingKEK.ToString(« N0 »))</div><div class="label">KEK manquant</div><div class="pct">$(if($c.Total -gt 0){[math] ::Round(($c.WithMissingKEK/$c.Total)*100,1)}else{0}) %</div></a>     <a class="carte » href="#s-err » onclick="openSection('d-err') » style="border-left-color :#dc3545 ; text-decoration :none"><div class="value » style="color :#dc3545">$($c.WithErrors.ToString(« N0 »))</div><div class="label">With Errors</div><div class="pct"><1 $($stats. PercentWithErrors) % : erreurs UEFI</div></a>     ><6 a class="carte » href="#s-tf » onclick="openSection('d-tf') » style="border-left-color :#dc3545 ; text-decoration :none"><div class="value » style="color :#dc3545"><9 $($c.TempFailures.ToString(« N0 »))</div><div class="label">Temp. Échecs</div><div class="pct">$(if($c.Total -gt 0){[math] ::Round(($c.TempFailures/$c.Total)*100,1)}else{0}) %</div></a>     <a class="carte » href="#s-pf » onclick="openSection('d-pf') » style="border-left-color :#721c24 ; text-decoration :none"><div class="value » style="color :#721c24">$($c.PermFailures.ToString(« N0 »))</div><div class="label">Non pris en charge><6 /div><classe div ="pct">$(if($c.Total -gt 0){[math] ::Round(($c.PermFailures/$c.Total)*100,1)}else{0}) %</div></a><3 </div>

<!-- vitesse de déploiement & expiration du certificat --> <div id="s-velocity » style="display :grid ; grid-template-columns :1fr 1fr ; gap :20px ; margin :15px 0"> <div class="section » style="margin :0">     <h2>&#128197 ; Vitesse de déploiement<> /h2     <div class="section-body open">         <div style="font-size :2.5em ; font-weight :700 ; color :#28a745">$($c.Updated.ToString(« N0 »))</div>         <div style="color :#666">appareils mis à jour à partir de $($c.Total.ToString(« N0 »))</div>         <div style="margin :10px 0 ; background :#e8eaf6 ; height :20px ; border-radius :10px ; overflow :hidden"><div style="background :#28a745 ; height :100 %; width :$($stats. PercentCertUpdated) %; border-radius :10px"></div></div>         <div style="font-size :.8em ; color :#888">$($stats. PercentCertUpdated) % complete</div>         <div style="margin-top :10px ; remplissage :10px ; background :#f8f9fa ; border-radius :8px ; font-size :.85em">             <appareils div><strong>Remaining :</strong> $($stNotUptodate.ToString(« N0 »)) ont besoin d’action</div>             <div><des appareils>blocage forts :</strong> $($c.WithErrors + $c.PermFailures + $c.TaskDisabledNotUpdated) (erreurs + permanent + tâche désactivée)</div>             <div><fort>Sécurisé à déployer :</strong> appareils $($stSafeList.ToString(« N0 »)) (même compartiment que réussite)</div>             $velocityHtml         <>/div     <>/div <>/div <div class="section » style="margin :0 ; border-left :4px solid #dc3545">     <h2 style="color :#dc3545">&#9888; Compte à rebours d’expiration du certificat</h2>     <div class="section-body open">         <div style="display :flex ; gap :15px ; margin-top :10px">             <div style="text-align :center ; remplissage :15px ; border-radius :8px ; min-width :120px ; background :linear-gradient(135deg,#fff5f5,#ffe0e0) ; border :2px solid #dc3545 ; flex :1">                 <div style="font-size :.65em ; color :#721c24 ; text-transform :uppercase ; font-weight :bold">&#9888; FIRST TO EXPIRE</div>                 ><4 div style="font-size :.85em ; font-weight :bold ; color :#dc3545 ; margin :3px 0"><5 KEK CA 2011</div>                 ><8 div id="daysKek » style="font-size :2.5em ; font-weight :700 ; color :#dc3545 ; line-height :1"><9 $daysToKek</div>                 ><2 div style="font-size :.8em ; color :#721c24"><3 days (24 juin 2026)><4 /div>             ><6>/div             ><8 div style="text-align :center ; remplissage :15px ; border-radius :8px ; min-width :120px ; background :linear-gradient(135deg,#fffef5,#fff3cd) ; border :2px solid #ffc107 ; flex :1"><9                 <div style="font-size :.65em ; color :#856404 ; text-transform :uppercase ; font-weight :bold">UEFI CA 2011</div>                 <div id="daysUefi » style="font-size :2.2em ; font-weight :700 ; color :#856404 ; line-height :1 ; margin :5px 0">$daysToUefi</div>                 <div style="font-size :.8em ; color :#856404">jours (27 juin 2026)</div>             <>/div             <div style="text-align :center ; remplissage :15px ; border-radius :8px ; min-width :120px ; background :linear-gradient(135deg,#f0f8ff,#d4edff) ; border :2px solid #0078d4 ; flex :1">                 <div style="font-size :.65em ; color :#0078d4 ; text-transform :uppercase ; font-weight :bold">Windows PCA</div>                 <div id="daysPca » style="font-size :2.2em ; font-weight :700 ; color :#0078d4 ; line-height :1 ; margin :5px 0">$daysToPca><2 /div><3                 <div style="font-size :.8em ; color :#0078d4">days (Oct 19, 2026)</div><7             <><9 /div         <><1 /div         <div style="margin-top :15px ; remplissage :10px ; background :#f8d7da ; border-radius :8px ; font-size :.85em ; border-left :4px solid #dc3545">             <strong>&#9888; CRITICAL :</strong> Tous les appareils doivent être mis à jour avant l’expiration du certificat. Les appareils non mis à jour à l’échéance ne peuvent pas appliquer les mises à jour de sécurité futures pour le Gestionnaire de démarrage et le démarrage sécurisé après l’expiration.<>/div     <>/div <>/div <> /div

graphiques<!-- --> <div class="charts">     <div class="chart-box"><h3>Deployment Status</h3><canvas id="deployChart » height="200"></canvas></div><5     <div class="chart-box"><h3><9 $mfrChartTitle</h3><canvas id="mfrChart » height="200"></canvas></div> <> /div

$(if ($historyData.Count -ge 1) { « graphique de tendance historique <!-- --> <div class='section'>     <h2 onclick='"toggle('d-trend')'">&#128200 ; Mettre à jour la progression dans le temps <class='top-link' href='#'>&#8593; Top</a></h2>     <div id='d-trend' class='section-body open'>         <canvas id='trendChart' height='120'></canvas>         <div style='font-size :.75em ; color :#888 ; margin-top :5px'>Lignes solides = actual data$(if ($historyData.Count -ge 2) { " | Trait en pointillés = projeté (doublement exponentiel : 2&#x2192;4&#x2192;8&#x2192;16... appareils par onde) » } else { " | Réexécutez l’agrégation demain pour voir les lignes de tendance et la projection " })</div>     <>/div </div> » })

téléchargements CSV<!-- --> <div class="section">     <h2 onclick="toggle('dl-csv')">&#128229 ; Télécharger les données complètes (CSV pour Excel) <class="top-link » href="#">Top</a></h2><2     <div id="dl-csv » class="section-body open » style="display :flex ; flex-wrap :wrap ; gap :5px">         <href="SecureBoot_not_updated_$timestamp.csv » style="display :inline-block ; background :#dc3545 ; color :#fff ; remplissage :6px 14px ; border-radius :5px ; text-decoration :none ; font-size :.8em">Non mis à jour ($($stNotUptodate.ToString(« N0 »))))</a><8         <href="SecureBoot_errors_$timestamp.csv » style="display :inline-block ; background :#dc3545 ; color :#fff ; remplissage :6px 14px ; border-radius :5px ; text-decoration :none ; font-size :.8em">Errors ($($c.WithErrors.ToString(« N0 »))))</a>         <a href="SecureBoot_action_required_$timestamp.csv » style="display :inline-block ; background :#fd7e14 ; color :#fff ; remplissage :6px 14px ; border-radius :5px ; text-decoration :none ; font-size :.8em">Action requise ($($c.ActionReq.ToString(« N0 »))))</a>         <un href="SecureBoot_known_issues_$timestamp.csv » style="display :inline-block ; background :#dc3545 ; color :#fff ; remplissage :6px 14px ; border-radius :5px ; text-decoration :none ; font-size :.8em">Problèmes connus ($($c.WithKnownIssues.ToString(« N0 »)))) </a>         <un href="SecureBoot_task_disabled_$timestamp.csv » style="display :inline-block ; background :#dc3545 ; color :#fff ; remplissage :6px 14px ; border-radius :5px ; text-decoration :none ; font-size :.8em">Task Disabled ($($c.TaskDisabled.ToString(« N0 »)))</a>         <href="SecureBoot_updated_devices_$timestamp.csv » style="display :inline-block ; background :#28a745 ; color :#fff ; remplissage :6px 14px ; border-radius :5px ; text-decoration :none ; font-size :.8em">Updated ($($c.Updated.ToString(« N0 »))))</a>         <un href="SecureBoot_Summary_$timestamp.csv » style="display :inline-block ; background :#6c757d ; color :#fff ; remplissage :6px 14px ; border-radius :5px ; text-decoration :none ; font-size :.8em">Résumé</a>         <div style="width :100 %; font-size :.75em ; color :#888 ; margin-top :5px">fichiers CSV ouverts dans Excel. Disponible lorsqu’il est hébergé sur le serveur web.</div>     <>/div <> /div

<!-- Répartition des fabricants --> <div class="section">     <h2 onclick="toggle('mfr')">By Manufacturer <a class="top-link » href="#">Top</a></h2><1     <div id="mfr » class="section-body open">     <tableau><><tr><th><1 Manufacturer><2 /th><th><5 Total><6 /th><th><9 Updated><0><9 /th><th><3 High Confidence><4 /th><th><7 Action Requise><8 /th></tr></thead><3     <tbody><5 $mfrTableRows><6 /tbody></table><9     <><1 /div <> /div

<!-- Sections de l’appareil (200 premiers téléchargements inclus + CSV) --> <div class="section » id="s-err">     <h2 onclick="toggle('d-err')">&#128308 ; Appareils avec erreurs ($($c.WithErrors.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-err » class="section-body">$tblErrors</div> <>/div <div class="section » id="s-ki">     <h2 onclick="toggle('d-ki') » style="color :#dc3545">&#128308 ; Problèmes connus ($($c.WithKnownIssues.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-ki » class="section-body">$tblKI</div> <>/div <div class="section » id="s-kek">     <h2 onclick="toggle('d-kek')">&#128992 ; KeK manquant - Événement 1803 ($($c.WithMissingKEK.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top</a></h2>     >&#8593; 0 div id="d-kek » class="section-body">&#8593; 1 $tblKEK</div> >&#8593; 4>/div >&#8593; 6 div class="section » id="s-ar">&#8593; 7     >&#8593; 8 h2 onclick="toggle('d-ar') » style="color :#fd7e14">&#128992 ; Action Obligatoire ($($c.ActionReq.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top><4 /a></h2><7     <div id="d-ar » class="section-body">$tblActionReq</div> <>/div <div class="section » id="s-uo">     <h2 onclick="toggle('d-uo') » style="color :#17a2b8">&#128309 ; Sous Observation ($($c.UnderObs.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-uo » class="section-body">$tblUnderObs</div> <>/div <div class="section » id="s-nu">     <h2 onclick="toggle('d-nu') » style="color :#dc3545">&#128308 ; Not Updated ($($stNotUptodate.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-nu » class="section-body">$tblNotUpd</div> <>/div >&#8593; 0 div class="section » id="s-td">&#8593; 1     >&#8593; 2 h2 onclick="toggle('d-td') » style="color :#dc3545">&#128308 ; Task Disabled ($($c.TaskDisabled.ToString(« N0 »))) >&#8593; 5 a class="top-link » href="#">&#8593; Top</a></h2><1     <div id="d-td » class="section-body">$tblTaskDis><4 /div><5 <><7 /div <div class="section » id="s-tf">     <h2 onclick="toggle('d-tf') » style="color :#dc3545">&#128308 ; Échecs temporaires ($($c.TempFailures.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-tf » class="section-body">$tblTemp</div> <>/div <div class="section » id="s-pf">     <h2 onclick="toggle('d-pf') » style="color :#721c24">&#128308 ; Échecs permanents / Non pris en charge ($($c.PermFailures.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-pf » class="section-body">$tblPerm</div> <>/div <div class="section » id="s-upd-pend">     <h2 onclick="toggle('d-upd-pend') » style="color :#6f42c1">&#9203; Update Pending ($($c.UpdatePending.ToString(« N0 »))) - Policy/WinCS Applied, En attente de mise à jour <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-upd-pend » class="section-body"><p style="color :#666 ; margin-bottom :10px">appareils où la clé AvailableUpdatesPolicy ou WinCS est appliquée, mais UEFICA2023Status est toujours NotStarted, InProgress ou null.</p>$tblUpdatePending</div> <>/div <div class="section » id="s-rip">     <h2 onclick="toggle('d-rip') » style="color :#17a2b8">&#128309 ; Lancement en cours ($($c.RolloutInProgress.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-rip » class="section-body">$tblRolloutIP</div> <>/div <div class="section » id="s-sboff">     <h2 onclick="toggle('d-sboff') » style="color :#6c757d">&#9899; SecureBoot OFF ($($c.SBOff.ToString(« N0 »))) - Hors portée <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-sboff » class="section-body">$tblSBOff</div> <>/div <div class="section » id="s-upd">     <h2 onclick="toggle('d-upd') » style="color :#28a745">&#128994 ; Appareils mis à jour ($($c.Updated.ToString(« N0 »))) <un class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-upd » class="section-body">$tblUpdated</div> <>/div <div class="section » id="s-nrb">     <h2 onclick="toggle('d-nrb') » style="color :#ffc107">&#128260 ; Mise à jour : nécessite un redémarrage ($($c.NeedsReboot.ToString(« N0 »))) <a class="top-link » href="#">&#8593; Top</a></h2>     <div id="d-nrb » class="section-body">$tblNeedsReboot</div> <> /div

<div class="footer">Tableau de bord de déploiement des certificats de démarrage sécurisé | $($stats généré. ReportGeneratedAt) | StreamingMode | Mémoire maximale : ${stPeakMemMB} Mo</div> </div><!-- /container -->

>de script< function toggle(id){var e=document.getElementById(id) ; e.classList.toggle('open')} fonction openSection(id){var e=document.getElementById(id) ; if(e&& !e.classList.contains('open')){e.classList.add('open')}} new Chart(document.getElementById('deployChart'),{type :'doughnut',data :{labels :['Updated','Update Pending','High Confidence','Under Observation','Action Required','Temp. Suspendu','Non pris en charge','SecureBoot OFF','With Errors'],datasets :[{data :[$($c.Updated),$($c.UpdatePending),$($c.HighConf),$$($c.UnderObs),$($c.ActionReq),$($c.TempPaused),$($c.NotSupported),$($c.SBOff),$(() $c.WithErrors)],backgroundColor :['#28a745','#6f42c1','#20c997','#17a2b8','#fd7e14','#6c757d','#721c24,'#adb5bd','#dc3545']}},options :{responsive :true,plugins :{legend :{position :'right',labels :{font :{size :11}}}}}}) ; new Chart(document.getElementById('mfrChart'),{type :'bar',data :{labels :[$mfrLabels],datasets :[{label :'Updated',data :[$mfrUpdated],backgroundColor :'#28a745'},{label :'Update Pending',data :[$mfrUpdatePending],backgroundColor :'#6f42c1'},{label :'High Confidence',data :[$mfrHighConf],backgroundColor :'#20c997'},{label :'Under Observation',data :[$mfrUnderObs],backgroundColor :'#17a2b8'},{label :'Action obligatoire',data :[$mfrActionReq],backgroundColor :'#fd7e14'},{ label :'Temp. Paused',data :[$mfrTempPaused],backgroundColor :'#6c757d'},{label :'Not Supported',data :[$mfrNotSupported],backgroundColor :'#721c24'},{label :'SecureBoot OFF',data :[$mfrSBOff],backgroundColor :'#adb5bd'},{label :'With Errors',data :[$mfrWithErrors],backgroundColor :'#dc3545'}]},options :{responsive :true,scales :{x :{stacked :true},y :{stacked :true}},plugins :{legend :{position :'top'}}}}}}) ; Graphique de tendances historiques if (document.getElementById('trendChart')) { var allLabels = [$allChartLabels] ; var actualUpdated = [$trendUpdated] ; var actualNotUpdated = [$trendNotUpdated] ; var actualTotal = [$trendTotal] ; var projData = [$projDataJS] ; var projNotUpdData = [$projNotUpdJS] ; var histLen = actualUpdated.length ; var projLen = projData.length ; var paddedUpdated = actualUpdated.concat(Array(projLen).fill(null)) ; var paddedNotUpdated = actualNotUpdated.concat(Array(projLen).fill(null)) ; var paddedTotal = actualTotal.concat(Array(projLen).fill(null)) ; var projLine = Array(histLen).fill(null) ; var projNotUpdLine = Array(histLen).fill(null) ; if (projLen > 0) { projLine[histLen-1] = actualUpdated[histLen-1] ; projLine = projLine.concat(projData) ; projNotUpdLine[histLen-1] = actualNotUpdated[histLen-1] ; projNotUpdLine = projNotUpdLine.concat(projNotUpdData) ; } var datasets = [     {label :'Updated',data :paddedUpdated,borderColor :'#28a745',backgroundColor :'rgba(40,167,69,0.1)',fill :true,tension :0.3,borderWidth :2},     {label :'Not Updated',data :paddedNotUpdated,borderColor :'#dc3545',backgroundColor :'rgba(220,53,69,0.1)',fill :true,tension :0.3,borderWidth :2},     {label :'Total',data :paddedTotal,borderColor :'#6c757d',borderDash :[5,5],fill :false,tension :0,pointRadius :0,borderWidth :1} ] ; if (projLen > 0) {     datasets.push({label :'Projected Updated (2x doublement)',data :projLine,borderColor :'#28a745',borderDash :[8,4],borderWidth :3,fill :false,tension :0.3,pointRadius :3,pointStyle :'triangle'}) ;     datasets.push({label :'Projected Not Updated',data :projNotUpdLine,borderColor :'#dc3545',borderDash :[8,4],borderWidth :3,fill :false,tension :0.3,pointRadius :3,pointStyle :'triangle'}) ; } new Chart(document.getElementById('trendChart'),{type :'line',data :{labels :allLabels,datasets :datasets},options :{responsive :true,scales :{y :{beginAtZero :true,title :{display :true,text :'Devices'}},x :{title :{display :true,text :'Date'}}},plugins :{legend :{position :'top'},title :{display :true,text :'Secure Boot Update Progress Over Time'}}}}}) ; } Compte à rebours dynamique (function(){var t=new Date(),k=new Date('2026-06-24'),u=new Date('2026-06-27'),p=new Date('2026-10-19') ; var dk=document.getElementById('daysKek'),du=document.getElementById('daysUefi'),dp=document.getElementById('daysPca') ; if(dk)dk.textContent=Math.max(0,Math.ceil((k-t)/864e5)) ; if(du)du.textContent=Math.max(0,Math.ceil((u-t)/864e5)) ; if(dp)dp.textContent=Math.max(0,Math.ceil((p-t)/864e5))}))() ; <> /script <>/body <>/html "@         [System.IO.File] ::WriteAllText($htmlPath, $htmlContent, [System.Text.UTF8Encoding] ::new($false))         # Conservez toujours une copie « Dernière » stable afin que les administrateurs n’ont pas besoin de suivre les horodatages         $latestPath = Join-Path $OutputPath « SecureBoot_Dashboard_Latest.html »         Copy-Item $htmlPath $latestPath -Force         $stTotal = $streamSw.Elapsed.TotalSeconds         # Enregistrer le manifeste de fichier pour le mode incrémentiel (détection rapide sans modification lors de la prochaine exécution)         if ($IncrementalMode -ou $StreamingMode) {             $stManifestDir = Join-Path $OutputPath .cache"             if (-not (Test-Path $stManifestDir)) { New-Item -ItemType Directory -Path $stManifestDir -Force | Out-Null }             $stManifestPath = Join-Path $stManifestDir « StreamingManifest.json »             $stNewManifest = @{}             Write-Host « Enregistrement du manifeste de fichier en mode incrémentiel... » -ForegroundColor Gray             foreach ($jf dans $jsonFiles) {                 $stNewManifest[$jf. FullName.ToLowerInvariant()] = @{                     LastWriteTimeUtc = $jf. LastWriteTimeUtc.ToString(« o »)                     Taille = $jf. Longueur                 }             }             Save-FileManifest -Manifest $stNewManifest -Path $stManifestPath             Write-Host « Manifeste enregistré pour les fichiers $($stNewManifest.Count) » -ForegroundColor DarkGray         }         # NETTOYAGE DE RÉTENTION         # Dossier réutilisable Orchestrator (Aggregation_Current) : conserver uniquement l’exécution la plus récente (1)         # Administration exécutions manuelles / autres dossiers : conserver les 7 dernières exécutions         # Résumé Des csv ne sont JAMAIS supprimés : ils sont minuscules (environ 1 Ko) et constituent la source de sauvegarde de l’historique des tendances         $outputLeaf = Split-Path $OutputPath -Leaf         $retentionCount = if ($outputLeaf -eq 'Aggregation_Current') { 1 } else { 7 }         Nombre de préfixes de fichier sécurisés pour propre (instantanés éphémères par exécution)         $cleanupPrefixes = @(             'SecureBoot_Dashboard_',             'SecureBoot_action_required_',             'SecureBoot_ByManufacturer_',             'SecureBoot_ErrorCodes_',             'SecureBoot_errors_',             'SecureBoot_known_issues_',             'SecureBoot_missing_kek_',             'SecureBoot_needs_reboot_',             'SecureBoot_not_updated_',             'SecureBoot_secureboot_off_',             'SecureBoot_task_disabled_',             'SecureBoot_temp_failures_',             'SecureBoot_perm_failures_',             'SecureBoot_under_observation_',             'SecureBoot_UniqueBuckets_',             'SecureBoot_update_pending_',             'SecureBoot_updated_devices_',             'SecureBoot_rollout_inprogress_',             'SecureBoot_NotUptodate_',             'SecureBoot_Kusto_'         )         # Rechercher tous les horodatages uniques à partir de fichiers pouvant être nettoyés uniquement         $cleanableFiles = Get-ChildItem $OutputPath -File -EA SilentlyContinue |             Where-Object { $f = $_. Nom; ($cleanupPrefixes | Where-Object { $f.StartsWith($_) }). Count -gt 0 }         $allTimestamps = @($cleanableFiles | ForEach-Object {             si ($_. Name -match '(\d{8}-\d{6})') { $Matches[1] }         } | Sort-Object -Unique -Décroissant)         if ($allTimestamps.Count -gt $retentionCount) {             $oldTimestamps = $allTimestamps | Select-Object -Ignorer $retentionCount             $removedFiles = 0 ; $freedBytes = 0             foreach ($oldTs dans $oldTimestamps) {                 foreach ($prefix dans $cleanupPrefixes) {                     $oldFiles = Get-ChildItem $OutputPath -File -Filter « ${prefix}${oldTs}* » -EA SilentlyContinue                     foreach ($f dans $oldFiles) {                         $freedBytes += $f.Length                         Remove-Item $f.FullName -Force -EA SilentlyContinue                         $removedFiles++                     }                 }             }             $freedMB = [math] ::Round($freedBytes / 1 Mo, 1)             Write-Host « Nettoyage de rétention : suppression des fichiers $removedFiles des anciennes exécutions $($oldTimestamps.Count), libération de ${freedMB} Mo (conservation des dernières $retentionCount + tous les csv Summary/NotUptodate) » -ForegroundColor DarkGray         }         Write-Host « 'n$(« = » * 60) » -ForegroundColor Cyan         Write-Host « STREAMING AGGREGATION COMPLETE » -ForegroundColor Vert         Write-Host (« = » * 60) -ForegroundColor Cyan         Write-Host " Total Devices : $($c.Total.ToString(« N0 »)) » -ForegroundColor White         Write-Host " NOT UPDATED : $($stNotUptodate.ToString(« N0 »)) ($($stats. PercentNotUptodate) %) » -ForegroundColor $(if ($stNotUptodate -gt 0) { « Yellow » } else { « Green » })         Write-Host " Mis à jour : $($c.Updated.ToString(« N0 »)) ($($stats. PercentCertUpdated) %) " -ForegroundColor Vert         Write-Host " With Errors : $($c.WithErrors.ToString(« N0 »)) » -ForegroundColor $(if ($c.WithErrors -gt 0) { « Red » } else { « Green » })         Write-Host « Peak Memory : ${stPeakMemMB} MB » -ForegroundColor Cyan         Write-Host " Time : $([math] ::Round($stTotal/60,1)) min » -ForegroundColor White         Write-Host « Tableau de bord : $htmlPath » -ForegroundColor White         return [PSCustomObject]$stats     }     mode de diffusion en continu #endregion } else {     Write-Error « Chemin d’entrée introuvable : $InputPath »     sortie 1 }                                                      

Besoin d’aide ?

Vous voulez plus d’options ?

Explorez les avantages de l’abonnement, parcourez les cours de formation, découvrez comment sécuriser votre appareil, etc.