Copie y pegue este script de ejemplo y modifíquelo según sea necesario para su entorno:

<# . SINOOPSIS     Agrega datos JSON de estado de arranque seguro de varios dispositivos a informes de resumen.

. DESCRIPCIÓN     Lee los archivos JSON de estado de arranque seguro recopilados y genera:     - Panel HTML con gráficos y filtrado     - Resumen por ConfidenceLevel     - Análisis único del bucket del dispositivo para probar la estrategia          Admite:     - Archivos por equipo: HOSTNAME_latest.json (recomendado)     - Un solo archivo      JSON     Deduplica automáticamente por HostName, conservando el tiempo de recopilación más reciente.     De manera predeterminada, solo se incluyen los dispositivos con confianza "Action Req" o "High"     para centrarse en cubos que requieren acción. Use -IncludeAllConfidenceLevels para invalidar.

. PARAMETER InputPath     Ruta de acceso a los archivos JSON:     - Carpeta: lee todos los archivos *_latest.json (o *.json si no _latest archivos)     - Archivo: lee un único archivo JSON

. PARAMETER OutputPath     Ruta de acceso para los informes generados (predeterminado: .\SecureBootReports)

. EJEMPLO     # Agregar desde una carpeta de archivos por equipo (recomendado)     .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$"     # Lee: \\contoso\SecureBootLogs$\*_latest.json

. EJEMPLO     # Ubicación de salida personalizada     .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -OutputPath "C:\Reports\SecureBoot"

. EJEMPLO     # Incluir solo acciones Req y Confianza alta (comportamiento predeterminado)     .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$"     # Excluye: Observación, Pausado, No compatible

. EJEMPLO     # Incluir todos los niveles de confianza (invalidar filtro)     .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncludeAllConfidenceLevels

. EJEMPLO     # Filtro de nivel de confianza personalizado     .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncludeConfidenceLevels @("Action Req", "High", "Observation")

. EJEMPLO     # ESCALA DE EMPRESA: modo incremental: solo se procesan los archivos modificados (ejecuciones posteriores rápidas)     .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncrementalMode     # Primera ejecución: carga completa ~2 horas para dispositivos de 500 000     # Ejecuciones siguientes: segundos si no hay cambios, minutos para deltas

. EJEMPLO     # Omitir HTML si no se ha cambiado nada (el más rápido para la supervisión)     .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncrementalMode -SkipReportIfUnchanged     # Si no ha cambiado ningún archivo desde la última ejecución: ~5 segundos

. EJEMPLO     # Modo solo resumen: omitir tablas de dispositivos grandes (1-2 minutos frente a más de 20 minutos)     .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -SummaryOnly     # Genera CSV pero omite el panel HTML con tablas de dispositivos completas

. NOTAS     Empareje con Detect-SecureBootCertUpdateStatus.ps1 para la implementación empresarial.Consulte GPO-DEPLOYMENT-GUIDE.md para obtener la guía de implementación completa.     El comportamiento predeterminado excluye los dispositivos de observación, pausado y no compatible     para centrar los informes solo en cubos de dispositivos accionables.#>

parámetro(     [Parámetro(Obligatorio = $true)]     [cadena]$InputPath,          [Parámetro(Obligatorio = $false)]     [string]$OutputPath = ".\SecureBootReports",          [Parámetro(Obligatorio = $false)]     [string]$ScanHistoryPath = ".\SecureBootReports\ScanHistory.json",          [Parámetro(Obligatorio = $false)]     [string]$RolloutStatePath, # Ruta de acceso a RolloutState.json para identificar dispositivos      InProgress     [Parámetro(Obligatorio = $false)]     [string]$RolloutSummaryPath, # Path to SecureBootRolloutSummary.json from Orchestrator (contiene datos de proyección)          [Parámetro(Obligatorio = $false)]     [string[]]$IncludeConfidenceLevels = @("Action Required", "High Confidence"), # Include these confidence levels (default: actionable buckets only)          [Parámetro(Obligatorio = $false)]     [switch]$IncludeAllConfidenceLevels, # Invalidar filtro para incluir todos los niveles      de confianza     [Parameter(Mandatory = $false)]     [conmutador]$SkipHistoryTracking,          [Parámetro(Obligatorio = $false)]     [switch]$IncrementalMode, # Habilitar el procesamiento delta: solo se cargan los archivos modificados desde la última ejecución          [Parámetro(Obligatorio = $false)]     [string]$CachePath, # Ruta de acceso al directorio de caché (predeterminado: OutputPath\.cache)          [Parámetro(Obligatorio = $false)]     [int]$ParallelThreads = 8, # Número de subprocesos paralelos para la carga de archivos (PS7+)          [Parameter(Mandatory = $false)]     [switch]$ForceFullRefresh, # Forzar recarga completa incluso en modo      incremental     [Parámetro(Obligatorio = $false)]     [switch]$SkipReportIfUnchanged, # Omitir la generación DE HTML/CSV si no se cambian los archivos (solo estadísticas de salida)          [Parámetro(Obligatorio = $false)]     [switch]$SummaryOnly, # Generar solo estadísticas de resumen (sin tablas de dispositivos grandes), mucho más rápido          [Parámetro(Obligatorio = $false)]     [switch]$StreamingMode # Modo eficiente en memoria: fragmentos del proceso, escribe CSV incrementalmente, mantiene solo resúmenes en memoria )

# Elevación automática a PowerShell 7 si está disponible (6 veces más rápido para conjuntos de datos grandes) if ($PSVersionTable.PSVersion.Major -lt 7) {     $pwshPath = Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source     if ($pwshPath) {         Write-Host "PowerShell $($PSVersionTable.PSVersion) detectado: volver a iniciarse con PowerShell 7 para un procesamiento más rápido..." -ForegroundColor Yellow         # Recompilar lista de argumentos a partir de parámetros dependientes         $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path)         foreach ($key en $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         salir $LASTEXITCODE     } }

$ErrorActionPreference = "Continuar" $timestamp = Get-Date -Formatear "aaaaMMdd-HHmmss" $scanTime = Get-Date -Formatear "aaaa-MM-dd HH:mm:ss" $DownloadUrl = "https://aka.ms/getsecureboot" $DownloadSubPage = "Ejemplos de implementación y supervisión"

# Nota: Este script no tiene dependencias con otros scripts. # Para el conjunto de herramientas completo, descarga desde: $DownloadUrl -> $DownloadSubPage

configuración de #region Write-Host "=" * 60 -ForegroundColor Cyan Write-Host "Agregación de datos de arranque seguro" -ForegroundColor Cyan Write-Host "=" * 60 -ForegroundColor Cyan

# Crear directorio de salida if (-not (Test-Path $OutputPath)) {     New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null }

# Cargar datos: admite formatos CSV (heredado) y JSON (nativo) Write-Host "'nCargar datos de: $InputPath" -ForegroundColor Yellow

# Función auxiliar para normalizar el objeto del dispositivo (controlar las diferencias de nombre de campo) Normalize-DeviceRecord de función {     parámetro($device)          # Handle Hostname vs HostName (JSON utiliza hostname, CSV utiliza HostName)     if ($device. PSObject.Properties['Hostname'] -and -not $device. PSObject.Properties['HostName']) {         $device | Add-Member -NotePropertyName 'HostName' -NotePropertyValue $device. Hostname -Force     }          # Handle Confidence vs ConfidenceLevel (JSON usa Confidence, CSV utiliza ConfidenceLevel)     # ConfidenceLevel es el nombre de campo oficial: asigne Confidence a él     if ($device. PSObject.Properties['Confidence'] -and -not $device. PSObject.Properties['ConfidenceLevel']) {         $device | Add-Member -NotePropertyName 'ConfidenceLevel' -NotePropertyValue $device. Confidencia - Fuerza     }          # Realizar un seguimiento del estado de actualización a través de Event1808Count O UEFICA2023Status="Updated"     # Esto permite realizar un seguimiento de cuántos dispositivos de cada depósito de confianza se han actualizado     $event 1808 = 0     if ($device. PSObject.Properties['Event1808Count']) {         $event 1808 = [int]$device. Event1808Count     }     $uefiCaUpdated = $false     if ($device. PSObject.Properties['UEFICA2023Status'] -and $device. UEFICA2023Status -eq "Updated") {         $uefiCaUpdated = $true     }          if ($event 1808 -gt 0 -o $uefiCaUpdated) {         # Marcar como actualizado para la lógica de panel o implementación, pero NO invalidar ConfidenceLevel         $device | Add-Member -NotePropertyName 'IsUpdated' -NotePropertyValue $true -Force     } else {         $device | Add-Member -NotePropertyName 'IsUpdated' -NotePropertyValue $false -Force                  # Clasificación ConfidenceLevel:         # - "Alta confianza", "En observación...", "Temporalmente pausada...", "No compatible..." = usar tal cual         # - Todo lo demás (nulo, vacío, "UpdateType:...", "Desconocido", "N/A") = corresponde a la acción Necesaria en los contadores.         # No se necesita normalización: la rama del contador de streaming lo controla     }          # Handle OEMManufacturerName vs WMI_Manufacturer (JSON usa OEM*, heredado usa WMI_*)     if ($device. PSObject.Properties['OEMManufacturerName'] y -not $device. PSObject.Properties['WMI_Manufacturer']) {         $device | Add-Member $device -NotePropertyName 'WMI_Manufacturer'-NotePropertyValue. OEMManufacturerName -Force     }          # Handle OEMModelNumber vs WMI_Model     if ($device. PSObject.Properties['OEMModelNumber'] -y -not $device. PSObject.Properties['WMI_Model']) {         $device | Add-Member $device -NotePropertyName 'WMI_Model'-NotePropertyValue. OEMModelNumber -Force     }          # Handle FirmwareVersion vs BIOSDescription     if ($device. PSObject.Properties['FirmwareVersion'] -y -not $device. PSObject.Properties['BIOSDescription']) {         $device | Add-Member -NotePropertyName 'BIOSDescription' -NotePropertyValue $device. FirmwareVersion -Force     }          devolver $device }

#region procesamiento incremental o administración de caché # Configurar rutas de caché if (-not $CachePath) {     $CachePath = Join-Path $OutputPath ".cache" } $manifestPath = Join-Path $CachePath "FileManifest.json" $deviceCachePath = Join-Path $CachePath "DeviceCache.json"

# Funciones de administración de caché Get-FileManifest de función {     parámetro([cadena]$Path)     if ($Path ruta de prueba) {         prueba {             $json = Get-Content $Path -Raw | ConvertFrom-Json             # Convertir PSObject a hashtable (compatible con PS5.1 - PS7 tiene -AsHashtable)             $ht = @{}             $json. PSObject.Properties | ForEach-Object { $ht[$_. Name] = $_. Valor }             devolver $ht         } captura {             devolver @{}         }     }     devolver @{} }

Save-FileManifest de función {     parámetro([hashtable]$Manifest, [cadena]$Path)     $dir = Split-Path $Path -Parent     if (-not (Test-Path $dir)) {         New-Item -ItemType Directory -Path $dir -Force | Out-Null     }     $Manifest | ConvertTo-Json -Depth 3 -Compress | Set-Content $Path - Fuerza }

Get-DeviceCache de función {     parámetro([cadena]$Path)     if ($Path ruta de prueba) {         prueba {             $cacheData = Get-Content $Path -Raw | ConvertFrom-Json             Write-Host " Caché de dispositivo cargada: dispositivos $($cacheData.Count) " -ForegroundColor DarkGray             devolver $cacheData         } captura {             Write-Host "Caché dañada, reconstruirá" -ForegroundColor Yellow             devolver @()         }     }     devolver @() }

Save-DeviceCache de función {     parámetro($Devices, [cadena]$Path)     $dir = Split-Path $Path -Parent     if (-not (Test-Path $dir)) {         New-Item -ItemType Directory -Path $dir -Force | Out-Null     }     # Convertir a matriz y guardar     $deviceArray = @($Devices)     $deviceArray | ConvertTo-Json -Depth 10 -Compress | Set-Content $Path - Fuerza     Write-Host " Caché de dispositivo guardada: dispositivos $($deviceArray.Count)" -ForegroundColor DarkGray }

Get-ChangedFiles de función {     parámetro(         [System.IO.FileInfo[]]$AllFiles,         [hashtable]$Manifest     )          $changed = [System.Collections.ArrayList]::new()     $unchanged = [System.Collections.ArrayList]::new()     $newManifest = @{}          # Generar búsqueda sin distinción de mayúsculas y minúsculas del manifiesto (normalizar a minúsculas)     $manifestLookup = @{}     foreach ($mk en $Manifest.Keys) {         $manifestLookup[$mk. ToLowerInvariant()] = $Manifest[$mk]     }          foreach ($file en $AllFiles) {         $key = $file. FullName.ToLowerInvariant() # Normalizar ruta de acceso a minúsculas         $lwt = $file. LastWriteTimeUtc.ToString("o")         $newManifest[$key] = @{             LastWriteTimeUtc = $lwt             Tamaño = $file. Longitud         }                  if ($manifestLookup.ContainsKey($key)) {             $cached = $manifestLookup[$key]             if ($cached. LastWriteTimeUtc -eq $lwt y $cached. Size -eq $file. Longitud) {                 [void]$unchanged. Add($file)                 continuar             }         }         [void]$changed. Add($file)     }          devolver @{         Cambiado = $changed         Unchanged = $unchanged         NewManifest = $newManifest     } }

# Carga rápida de archivos paralelos mediante procesamiento por lotes Load-FilesParallel de función {     parámetro(         [System.IO.FileInfo[]]$Files,         [int]$Threads = 8     )

$totalFiles = $Files. Contar     # Usa lotes de ~1000 archivos cada uno para un mejor control de memoria     $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 = [matemáticas]::Mín($i + $batchSize, $totalFiles)         $batch = $Files[$i.. ($end-1)]         $batches. Add($batch)     }     Write-Host " ($($batches. Contar) lotes de ~$batchSize archivos cada uno)" -NoNewline -ForegroundColor DarkGray     $flatResults = [System.Collections.Generic.List[object]]::new()     # Comprobar si PowerShell 7+ paralelo está disponible     $canParallel = $PSVersionTable.PSVersion.Major -ge 7     if ($canParallel -and $Threads -gt 1) {         # PS7+: Procesar lotes en paralelo         $results = $batches | ForEach-Object -ThrottleLimit $Threads -Parallel {             $batchFiles = $_             $batchResults = [System.Collections.Generic.List[object]]::new()             foreach ($file en $batchFiles) {                 prueba {                     $content = [System.IO.File]::ReadAllText($file. FullName) | ConvertFrom-Json                     $batchResults.Add($content)                 } captura { }             }             $batchResults.ToArray()         }         foreach ($batch en $results) {             if ($batch) { foreach ($item in $batch) { $flatResults.Add($item) } }         }     } else {         Fallback # PS5.1: procesamiento secuencial (aún rápido para archivos <10 000)         foreach ($file en $Files) {             prueba {                 $content = [System.IO.File]::ReadAllText($file. FullName) | ConvertFrom-Json                 $flatResults.Add($content)             } captura { }         }     }     return $flatResults.ToArray() } #endregion                         

$allDevices = @() if (Test-Path $InputPath -PathType Leaf) {     # Archivo JSON único     if ($InputPath -like "*.json") {         $jsonContent = Get-Content -Path $InputPath -Raw | ConvertFrom-Json         $allDevices = @($jsonContent) | ForEach-Object { Normalize-DeviceRecord $_ }         Write-Host registros "$($allDevices.Count) cargados del archivo"     } else {         Write-Error "Solo se admite el formato JSON. El archivo debe tener extensión de .json".         salir 1     } } elseif (test-path $InputPath -PathType Container) {     # Carpeta: solo JSON     $jsonFiles = @(Get-ChildItem -Path $InputPath -Filter "*.json" -Recurse -ErrorAction SilentlyContinue |                    Where-Object { $_. Name -notmatch "ScanHistory|RolloutState|RolloutPlan" })          # Prefiero *_latest.json archivos si existen (modo por máquina)     $latestJson = $jsonFiles | Where-Object { $_. Name -like "*_latest.json" }     if ($latestJson.Count -gt 0) { $jsonFiles = $latestJson }          $totalFiles = $jsonFiles.Count          if ($totalFiles -eq 0) {         Write-Error "No se encontraron archivos JSON en: $InputPath"         salir 1     }          Write-Host "Encontrados $totalFiles archivos JSON" -ForegroundColor Gray          # Función auxiliar para que coincida con los niveles de confianza (controla formularios cortos y completos)     # Definido de forma anticipada para que puedan usarlo tanto StreamingMode como las rutas de acceso normales.     Test-ConfidenceLevel de función {         parámetro([cadena]$Value, [cadena]$Match)         if ([string]::IsNullOrEmpty($Value)) { return $false }         conmutador ($Match) {             "HighConfidence" { return $Value -eq "High Confidence" }             "UnderObservation" { return $Value -like "Under Observation*" }             "ActionRequired" { return ($Value -like "*Action Required*" -or $Value -eq "Action Required") }             "Temporalmente usada" { return $Value -like "Temporarily Paused*" }             "NotSupported" { return ($Value -like "Not Supported*" -or $Value -eq "Not Supported") }             valor predeterminado { return $false }         }     }          #region MODO DE STREAMING: Procesamiento eficiente de memoria para conjuntos de datos grandes     # Usa siempre StreamingMode para un procesamiento eficiente de la memoria y un panel de estilo nuevo.     if (-not $StreamingMode) {         Write-Host "Auto-enabling StreamingMode (new-style dashboard)" -ForegroundColor Yellow         $StreamingMode = $true         if (-not $IncrementalMode) { $IncrementalMode = $true }     }          # Cuando -StreamingMode está habilitado, los archivos de proceso en fragmentos mantienen solo contadores en memoria.# Los datos de nivel de dispositivo se escriben en archivos JSON por fragmento para la carga bajo demanda en el panel.# Uso de memoria: ~1,5 GB independientemente del tamaño del conjunto de datos (frente a 10-20 GB sin streaming).if ($StreamingMode) {         Write-Host "MODO DE STREAMING habilitado- procesamiento eficiente en memoria" -ForegroundColor Green         $streamSw = [System.Diagnostics.Stopwatch]::StartNew()         # COMPROBACIÓN INCREMENTAL: Si no ha cambiado ningún archivo desde la última ejecución, omita el procesamiento por completo         if ($IncrementalMode -and -not $ForceFullRefresh) {             $stManifestDir = Join-Path $OutputPath ".cache"             $stManifestPath = Join-Path $stManifestDir "StreamingManifest.json"             if ($stManifestPath ruta de prueba) {                 Write-Host "Comprobación de cambios desde la última ejecución de streaming..." -ForegroundColor Cyan                 $stOldManifest = Get-FileManifest -Path $stManifestPath                 if ($stOldManifest.Count -gt 0) {                     $stChanged = $false                     # Comprobación rápida: ¿el mismo número de archivos?                     if ($stOldManifest.Count -eq $totalFiles) {                         # Compruebe los 100 archivos MÁS RECIENTES (ordenados por Orden descendente LastWriteTime)                         # Si algún archivo cambia, tendrá la marca de tiempo más reciente y aparecerá primero                         $sampleSize = [matemáticas]:Min(100, $totalFiles)                         $sampleFiles = $jsonFiles | Sort-Object LastWriteTimeUtc -Descending | Select-Object: primer $sampleSize                         foreach ($sf en $sampleFiles) {                             $sfKey = $sf. FullName.ToLowerInvariant()                             if (-not $stOldManifest.ContainsKey($sfKey)) {                                 $stChanged = $true                                 romper                             }                             # Comparar marcas de tiempo: la memoria caché puede ser DateTime o cadena después de la ida y vuelta JSON                             $cachedLWT = $stOldManifest[$sfKey]. LastWriteTimeUtc                             $fileDT = $sf. LastWriteTimeUtc                             prueba {                                 # Si la caché ya es DateTime (ConvertFrom-Json auto-converts), úsalo directamente                                 if ($cachedLWT -is [DateTime]) {                                     $cachedDT = $cachedLWT.ToUniversalTime()                                 } else {                                     $cachedDT = [DateTimeOffset]::P arse("$cachedLWT"). UtcDateTime                                 }                                 if ([math]::Abs(($cachedDT - $fileDT). TotalSeconds) -gt 1) {                                     $stChanged = $true                                     romper                                 }                             } captura {                                 $stChanged = $true                                 romper                             }                         }                     } else {                         $stChanged = $true                     }                     if (-not $stChanged) {                         # Comprobar si existen archivos de salida                         $stSummaryExists = Get-ChildItem (join-path $OutputPath "SecureBoot_Summary_*.csv") -EA SilentlyContinue | Select-Object -Primero 1                         $stDashExists = Get-ChildItem (join-path $OutputPath "SecureBoot_Dashboard_*.html") -EA SilentlyContinue | Select-Object -First 1                         if ($stSummaryExists y $stDashExists) {                             Write-Host "No se detectaron cambios ($totalFiles archivos sin cambios) - omitiendo el procesamiento" -ForegroundColor Green                             Write-Host " Último panel: $($stDashExists.FullName)" -ForegroundColor White                             $cachedStats = Get-Content $stSummaryExists.FullName | ConvertirFrom-Csv                             Write-Host " Dispositivos: $($cachedStats.TotalDevices) | Actualizado: $($cachedStats.Updated) | Errores: $($cachedStats.WithErrors)" -ForegroundColor Gray                             Write-Host " Completado en $([math]::Round($streamSw.Elapsed.TotalSeconds, 1)s (sin procesamiento necesario)" -ForegroundColor Green                             devolver $cachedStats                         }                     } else {                         # DELTA PATCH: Encontrar exactamente qué archivos han cambiado                         Write-Host " Cambios detectados - identificación de archivos cambiados..." -ForegroundColor Yellow                         $changedFiles = [System.Collections.ArrayList]::new()                         $newFiles = [System.Collections.ArrayList]::new()                         foreach ($jf en $jsonFiles) {                             $jfKey = $jf. FullName.ToLowerInvariant()                             if (-not $stOldManifest.ContainsKey($jfKey)) {                                 [void]$newFiles.Add($jf)                             } else {                                 $cachedLWT = $stOldManifest[$jfKey]. LastWriteTimeUtc                                 $fileDT = $jf. LastWriteTimeUtc                                 prueba {                                     $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 " Cambiado: $($changedFiles.Count) | Nuevo: $($newFiles.Count) | Total: $totalChanged ($changePct%)" -ForegroundColor Yellow                         if ($totalChanged -gt 0 -and $changePct -lt 10) {                             # DELTA PATCH MODE: <10 % cambiado, parchear datos existentes                             Write-Host " Modo de revisión diferencial ($changePct% < 10 %): patching $totalChanged archivos..." -ForegroundColor Green                             $dataDir = Join-Path $OutputPath "datos"                             # Cargar registros de dispositivo nuevos o cambiados                             $deltaDevices = @{}                             $allDeltaFiles = @($changedFiles) + @($newFiles)                             foreach ($df en $allDeltaFiles) {                                 prueba {                                     $devData = Get-Content $df. FullName -Raw | ConvertFrom-Json                                     $dev = Normalize-DeviceRecord $devData                                     si ($dev. HostName) { $deltaDevices[$dev. HostName] = $dev }                                 } captura { }                             }                             Write-Host " $($deltaDevices.Count) cambiado los registros de dispositivo cargados" -ForegroundColor Gray                             # Para cada categoría JSON: quitar entradas antiguas para nombres de host modificados, agregar nuevas entradas                             $categoryFiles = @("errores", "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 en $deltaDevices.Keys) { [void]$changedHostnames.Add($hn) }                             foreach ($cat en $categoryFiles) {                                 $catPath = Join-Path $dataDir "$cat.json"                                 if ($catPath ruta de prueba) {                                     prueba {                                         $catData = Get-Content $catPath -Raw | ConvertFrom-Json                                         # Quitar entradas antiguas para nombres de host modificados                                         $catData = @($catData | Where-Object { -not $changedHostnames.Contains($_. HostName) })                                         # Volver a clasificar cada dispositivo modificado en categorías                                         # (se agregará a continuación después de la clasificación)                                         $catData | ConvertTo-Json -Profundidad 5 | Set-Content $catPath -Encoding UTF8                                     } captura { }                                 }                             }                             # Clasificar cada dispositivo modificado y anexar a los archivos de categoría correctos                             foreach ($dev en $deltaDevices.Values) {                                 $slim = [ordered]@{                                     HostName = $dev. Nombre de host                                     WMI_Manufacturer = if ($dev. PSObject.Properties['WMI_Manufacturer']) { $dev. WMI_Manufacturer } más { "" }                                     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 } más { $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) y $dev. UEFICA2023Error -ne "0" -and $dev. UEFICA2023Error -ne "")                                 $tskDis = ($dev. SecureBootTaskEnabled -eq $false o $dev. SecureBootTaskStatus -eq 'Disabled' o $dev. SecureBootTaskStatus -eq 'NotFound')                                 $tskNF = ($dev. SecureBootTaskStatus -eq 'NotFound')                                 $sbOn = ($dev. SecureBootEnabled -ne $false y "$($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 - o $dev. FaltaKEK -eq $true)                                 $hKI = ((-not [string]::IsNullOrEmpty($dev. SkipReasonKnownIssue)) -o (-not [string]::IsNullOrEmpty($dev. KnownIssueId))                                 $rStat = if ($dev. PSObject.Properties['RolloutStatus']) { $dev. RolloutStatus } else { "" }                                 # Anexar a archivos de categoría coincidentes                                 $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 en $targets) {                                     $tgtPath = Join-Path $dataDir "$tgt.json"                                     if ($tgtPath ruta de prueba) {                                         $existing = Get-Content $tgtPath -Raw | ConvertFrom-Json                                         $existing = @($existing) + @([PSCustomObject]$slim)                                         $existing | ConvertTo-Json -Profundidad 5 | Set-Content $tgtPath -Encoding UTF8                                     }                                 }                             }                             # Regenerar CSV de JSONs parcheados                             Write-Host " Regeneración de CSV a partir de datos parcheados..." -ForegroundColor Gray                             $newTimestamp = Get-Date -Formatear "aaaaMMdd-HHmmss"                             foreach ($cat en $categoryFiles) {                                 $catJsonPath = Join-Path $dataDir "$cat.json"                                 $catCsvPath = Join-Path $OutputPath "newTimestamp.csv SecureBoot_${cat}_$"                                 if ($catJsonPath ruta de prueba) {                                     prueba {                                         $catJsonData = Get-Content $catJsonPath -Raw | ConvertFrom-Json                                         if ($catJsonData.Count -gt 0) {                                             $catJsonData | Export-Csv -Path $catCsvPath -NoTypeInformation -Encoding UTF8                                         }                                     } captura { }                                 }                             }                             # Volver a contar estadísticas de los archivos JSON parcheados                             Write-Host " Recalculando el resumen de datos con revisiones..." -ForegroundColor Gray                             $patchedStats = [ordered]@{ ReportGeneratedAt = (Get-Date). ToString("aaaa-MM-dd 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 en $categoryFiles) {                                 $catPath = Join-Path $dataDir "$cat.json"                                 $cnt = 0                                 if (test-path $catPath) { try { $cnt = (Get-Content $catPath -Raw | ConvertFrom-Json). Count } catch { } }                                 conmutador ($cat) {                                     "updated_devices" { $pUpdated = $cnt }                                     "errores" { $pErrors = $cnt }                                     "known_issues" { $pKI = $cnt }                                     "missing_kek" { $pKEK = $cnt }                                     "not_updated" { } # calculado                                     "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). Contar                             $pTotal = $pUpdated + $pNotUpdated + $pSBOff                             Write-Host " Revisión diferencial completa: $totalChanged dispositivos actualizados" -ForegroundColor Green                             Write-Host " Total: $pTotal | Actualizado: $pUpdated | NotUpdated: $pNotUpdated | Errores: $pErrors" -ForegroundColor White                             # Manifiesto de actualización                             $stManifestDir = Join-Path $OutputPath ".cache"                             $stNewManifest = @{}                             foreach ($jf en $jsonFiles) {                                 $stNewManifest[$jf. FullName.ToLowerInvariant()] = @{                                     LastWriteTimeUtc = $jf. LastWriteTimeUtc.ToString("o"); Tamaño = $jf. Longitud                                 }                             }                             Save-FileManifest -Manifest $stNewManifest -Path $stManifestPath                             Write-Host " Completado en $([math]::Round($streamSw.Elapsed.TotalSeconds, 1)s (parche delta - dispositivos $totalChanged)" -ForegroundColor Green                             # Vuelve a procesar el streaming completo para regenerar el panel HTML                             # Los archivos de datos ya están parcheados, por lo que esto garantiza que el panel se mantenga actualizado                             Write-Host " Regeneración del panel de datos parcheados..." -ForegroundColor Yellow                         } else {                             Write-Host " $changePct% de archivos cambiados (>= 10 %) - se requiere un procesamiento de streaming completo" -ForegroundColor Yellow                         }                     }                 }             }         }         # Crear subdirectorio de datos para archivos JSON de dispositivos a petición         $dataDir = Join-Path $OutputPath "datos"         if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir -Force | Out-Null }         # Desduplicación a través de HashSet (O(1) por búsqueda, ~50 MB para nombres de host de 600 000)         $seenHostnames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)         # Contadores de resumen ligeros (reemplaza $allDevices + $uniqueDevices en memoria)         $c = @{             Total = 0; SBEnabled = 0; SBOff = 0             Actualizado = 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         }         # Seguimiento de cubos para AtRisk/SafeList (conjuntos ligeros)         $stFailedBuckets = [System.Collections.Generic.HashSet[string]]::new()         $stSuccessBuckets = [System.Collections.Generic.HashSet[string]]::new()         $stAllBuckets = @{}         $stMfrCounts = @{}         $stErrorCodeCounts = @{}; $stErrorCodeSamples = @{}         $stKnownIssueCounts = @{}         # Archivos de datos de dispositivo en modo por lotes: se acumulan por fragmento, se vacían en los límites de fragmentos         $stDeviceFiles = @("errores", "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 en $stDeviceFiles) {             $dfPath = Join-Path $dataDir "$dfName.json"             [System.IO.File]::WriteAllText($dfPath, "['n", [System.Text.Encoding]::UTF8)             $stDeviceFilePaths[$dfName] = $dfPath; $stDeviceFileCounts[$dfName] = 0         }         # Slim device record for JSON output (solo campos esenciales, ~200 bytes vs ~2KB completo)         Get-SlimDevice de función {             parámetro($Dev)             devolver [pedido]@{                 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 }             }         }         # Vacía el lote al archivo JSON (modo de datos anexados)         función Flush-DeviceBatch {             parámetro([string]$StreamName, [System.Collections.Generic.List[object]]$Batch)             if ($Batch.Count -eq 0) { return }             $fPath = $stDeviceFilePaths[$StreamName]             $fSb = [System.Text.StringBuilder]::new()             foreach ($fDev en $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)         }         # MAIN STREAMING LOOP         $stChunkSize = if ($totalFiles -le 10000) { $totalFiles } else { 10000 }         $stTotalChunks = [matemáticas]::Techo($totalFiles / $stChunkSize)         $stPeakMemMB = 0         if ($stTotalChunks -gt 1) {             Write-Host "Procesando archivos de $totalFiles en $stTotalChunks fragmentos de $stChunkSize (secuencias de streaming $ParallelThreads hilos):" -ForegroundColor Cyan         } else {             Write-Host "Procesando archivos $totalFiles (streaming, $ParallelThreads hilos):" -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): " -NoNewline -ForegroundColor Gray             } else {                 Write-Host " Cargando archivos $($cFiles.Count): " -NoNewline -ForegroundColor Gray             }             $cSw = [System.Diagnostics.Stopwatch]::StartNew()             $rawDevices = Load-FilesParallel -Files $cFiles -Threads $ParallelThreads             # Listas por lotes por fragmento             $cBatches = @{}             foreach ($df en $stDeviceFiles) { $cBatches[$df] = [System.Collections.Generic.List[object]]::new() }             $cNew = 0; $cDupe = 0             foreach ($raw en $rawDevices) {                 if (-not $raw) { continue }                 $device = Normalize-DeviceRecord $raw                 $hostname = $device. Nombre de host                 if (-not $hostname) { continue }                 if ($seenHostnames.Contains($hostname)) { $cDupe++; continue }                 [void]$seenHostnames.Add($hostname)                 $cNew++; $c.Total++                 $sbOn = ($device. SecureBootEnabled -ne $false - y "$($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'] -and $device. ConfidenceLevel) { "$($device. ConfidenceLevel)" } else { "" }                 $hasErr = (-not [string]::IsNullOrEmpty($device. UEFICA2023Error) - y "$($device. UEFICA2023Error)" -ne "0" -y "$($device. UEFICA2023Error)" -ne "")                 $tskDis = ($device. SecureBootTaskEnabled -eq $false -o "$($device. SecureBootTaskStatus)" -eq 'Disabled' -o "$($device. SecureBootTaskStatus)" -eq 'NotFound')                 $tskNF = ("$($device. SecureBootTaskStatus)" -eq 'NotFound')                 $bid = if ($device. PSObject.Properties['BucketId'] - y $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 - o $device. MissingKEK -eq $true -o "$($device. MissingKEK)" -eq "True")                 $hKI = ((-not [cadena]::IsNullOrEmpty($device. SkipReasonKnownIssue)) -o (-not [string]::IsNullOrEmpty($device. KnownIssueId))                 $rStat = if ($device. PSObject.Properties['RolloutStatus']) { $device. RolloutStatus } else { "" }                 $mfr = if ($device. PSObject.Properties['WMI_Manufacturer'] -y -not [string]::IsNullOrEmpty($device. WMI_Manufacturer)) { $device. WMI_Manufacturer } más { "Desconocido" }                 $bid = if (-not [string]::IsNullOrEmpty($bid)) { $bid } else { "" }                 # Marca pendiente de actualización de precomputo (directiva/WinCS aplicado, estado no actualizado, SB ON, tarea no deshabilitada)                 $uefiStatus = if ($device. PSObject.Properties['UEFICA2023Status']) { "$($device. UEFICA2023Status)" } else { "" }                 $hasPolicy = ($device. PSObject.Properties['AvailableUpdatesPolicy'] -and $null -ne $device. AvailableUpdatesPolicy -y "$($device. AvailableUpdatesPolicy)" -ne '')                 $hasWinCS = ($device. PSObject.Properties['WinCSKeyApplied'] y $device. WinCSKeyApplied -eq $true)                 $statusPending = ([cadena]::IsNullOrEmpty($uefiStatus) -o $uefiStatus -eq 'NotStarted' -o $uefiStatus -eq 'InProgress')                 $isUpdatePending = (($hasPolicy -o $hasWinCS) -y $statusPending -y -not $isUpd -y $sbOn -and -not $tskDis)                 if ($isUpd) {                     $c.Updated++; [void]$stSuccessBuckets.Add($bid); $cBatches["updated_devices"]. Add((Get-SlimDevice $device))                     # Seguimiento de dispositivos actualizados que necesitan reiniciarse (UEFICA2023Status=Updated but Event1808=0)                     if ($e 1808 -eq 0) { $c.NeedsReboot++; $cBatches["needs_reboot"]. Add((Get-SlimDevice $device)) }                 }                 elseif (-no $sbOn) {                     # SecureBoot OFF: fuera del ámbito, no clasificar por confianza                 }                 en caso contrario {                     if ($isUpdatePending) { } # Contabilizó por separado en actualización pendiente, que se excluyen mutuamente para gráfico circular                     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["errores"]. Add((Get-SlimDevice $device))                     $ec = $device. UEFICA2023Error                     if (-not $stErrorCodeCounts.ContainsKey($ec)) { $stErrorCodeCounts[$ec] = 0; $stErrorCodeSamples[$ec] = @() }                     $stErrorCodeCounts[$ec]++                     si ($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++ }                 # Actualización pendiente: directiva o WinCS aplicado, estado pendiente, SB ON, tarea no deshabilitada                 if ($isUpdatePending) {                     $c.UpdatePending++; $cBatches["update_pending"]. Add((Get-SlimDevice $device))                 }                 if (-not $isUpd -and $sbOn) { $cBatches["not_updated"]. Add((Get-SlimDevice $device)) }                 # En Dispositivos de observación (independiente de la acción requerida)                 if (-not $isUpd -and (Test-ConfidenceLevel $conf 'UnderObservation')) { $cBatches["under_observation"]. Add((Get-SlimDevice $device)) }                 # Acción Requerida: no actualizado, SB ON, no coincide con otras categorías de confianza, no actualización pendiente                 if (-not $isUpd -and $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; Actualizado=0; UpdatePending=0; HighConf=0; UnderObs=0; ActionReq=0; TempPaused=0; NotSupported=0; SBOff=0; WithErrors=0 } } }                 $stMfrCounts[$mfr]. Total++                 if ($isUpd) { $stMfrCounts[$mfr]. Actualizado++ }                 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++ }                 más { $stMfrCounts[$mfr]. ActionReq++ }                 if ($hasErr) { $stMfrCounts[$mfr]. WithErrors++ }                 # Realizar un seguimiento de todos los dispositivos por cubo (incluido el BucketId vacío)                 $bucketKey = if ($bid -and $bid -ne "") { $bid } else { "(empty)" }                 if (-not $stAllBuckets.ContainsKey($bucketKey)) {                     $stAllBuckets[$bucketKey] = @{ Count=0; Actualizado=0; Manufacturer=$mfr; Model=""; BIOS="" }                     if ($device. PSObject.Properties['WMI_Model']) { $stAllBuckets[$bucketKey]. Model = $device. WMI_Model }                     if ($device. PSObject.Properties['BIOSDescription']) { $stAllBuckets[$bucketKey]. BIOS = $device. BIOSDescription }                 }                 $stAllBuckets[$bucketKey]. Count++                 if ($isUpd) { $stAllBuckets[$bucketKey]. Actualizado++ }             }             # Vacía los lotes en el disco             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) / 1MB, 0)             if ($cMem -gt $stPeakMemMB) { $stPeakMemMB = $cMem }             Write-Host " +$cNew nuevos duplicados de $cDupe, ${cTime}s | Mem: ${cMem}MB$cEta" -ForegroundColor Green         }         # Finalizar matrices JSON         foreach ($dfName en $stDeviceFiles) {             [System.IO.File]::AppendAllText($stDeviceFilePaths[$dfName], "'n]", [System.Text.Encoding]::UTF8)             Write-Host " $dfName.json: dispositivos $($stDeviceFileCounts[$dfName]) -ForegroundColor DarkGray         }         # Calcular estadísticas derivadas         $stAtRisk = 0; $stSafeList = 0         foreach ($bid en $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 = recuento de not_updated lote (dispositivos con SB ON y no actualizado)         $stNotUptodate = $stDeviceFileCounts["not_updated"]         $stats = [ordered]@{             ReportGeneratedAt = (Get-Date). ToString("aaaa-MM-dd HH:mm:ss")             TotalDevices = $c.Total; SecureBootEnabled = $c.SBEnabled; SecureBootOFF = $c.SBOff             Actualizado = $c.Updated; HighConfidence = $c.HighConf; UnderObservation = $c.UnderObs             ActionRequired = $c.ActionReq; TemporalmentePaused = $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"         }         # Escribir CSV         [PSCustomObject]$stats | Export-Csv -Path (join-path $OutputPath "timestamp.csv SecureBoot_Summary_$") -NoTypeInformation -Encoding UTF8         $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Descendente | ForEach-Object {             [PSCustomObject]@{ Manufacturer=$_. Tecla; Count=$_. Value.Total; Actualizado=$_. Value.Updated; HighConfidence=$_. Value.HighConf; ActionRequired=$_. Value.ActionReq }         } | Export-Csv -Path (join-path $OutputPath "SecureBoot_ByManufacturer_$timestamp.csv") -NoTypeInformation -Encoding UTF8         $stErrorCodeCounts.GetEnumerator() | Sort-Object valor -Descendente | ForEach-Object {             [PSCustomObject]@{ ErrorCode=$_. Tecla; Count=$_. Valor; SampleDevices=($stErrorCodeSamples[$_. Clave] -join ", ") }         } | Export-Csv -Path (join-path $OutputPath "timestamp.csv SecureBoot_ErrorCodes_$") -NoTypeInformation -Encoding UTF8         $stAllBuckets.GetEnumerator() | Sort-Object { $_. Value.Count } -Descending | ForEach-Object {             [PSCustomObject]@{ BucketId=$_. Tecla; Count=$_. Value.Count; Actualizado=$_. Value.Updated; NotUpdated=$_. Value.Count-$_. Value.Updated; Manufacturer=$_. Value.Manufacturer }         } | Export-Csv -Path (join-path $OutputPath "SecureBoot_UniqueBuckets_$timestamp.csv") -NoTypeInformation -Encoding UTF8         # Generar CSV compatibles con organizadores (nombres de archivo esperados para Start-SecureBootRolloutOrchestrator.ps1)         $notUpdatedJsonPath = Join-Path $dataDir "not_updated.json"         if ($notUpdatedJsonPath ruta de prueba) {             prueba {                 $nuData = Get-Content $notUpdatedJsonPath -Raw | ConvertFrom-Json                 if ($nuData.Count -gt 0) {                     # CSV NotUptodate: el organizador busca *NotUptodate*.csv                     $nuData | Export-Csv -Path (join-path $OutputPath "SecureBoot_NotUptodate_$timestamp.csv") -NoTypeInformation -Encoding UTF8                     Write-Host CSV organizador: SecureBoot_NotUptodate_$timestamp.csv (dispositivos $($nuData.Count)" -ForegroundColor Gray                 }             } captura { }         }         # Escribir datos JSON para el panel         $stats | ConvertTo-Json -Profundidad 3 | Set-Content (Join-Path $dataDir "summary.json") -Encoding UTF8         # SEGUIMIENTO HISTÓRICO: Guardar punto de datos para el gráfico de tendencia         # Use una ubicación de caché estable para que los datos de tendencia persistan en las carpetas de agregación con marcas de tiempo.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   # Si OutputPath tiene el aspecto de "...\Aggregation_yyyyMMdd_HHmmss", la caché se almacenará en la carpeta principal.# En caso contrario, la memoria caché se encuentra dentro de OutputPath.$parentDir = Split-Path $OutputPath -Parent         $leafName = Split-Path $OutputPath -Hoja         if ($leafName -match '^Aggregation_\d{8}' -or $leafName -eq 'Aggregation_Current') {             # Carpeta con marca de tiempo creada por el organizador: usa el elemento primario para una caché estable             $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 ($historyPath ruta de prueba) {             prueba { $historyData = @(Get-Content $historyPath -Raw | ConvertFrom-Json) } catch { $historyData = @() }         }         # Compruebe también dentro de OutputPath\.cache\ (ubicación heredada de versiones anteriores)         # Combinar los puntos de datos que aún no están en el historial 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) {                 prueba {                     $innerData = @(Get-Content $innerHistoryPath -Raw | ConvertFrom-Json)                     $existingDates = @($historyData | ForEach-Object { $_. Fecha })                     foreach ($entry en $innerData) {                         si ($entry. Fecha y $entry. Date -notin $existingDates) {                             $historyData += $entry                         }                     }                     if ($innerData.Count -gt 0) {                         Write-Host " Puntos de datos $($innerData.Count) combinados de la caché interna" -ForegroundColor DarkGray                     }                 } captura { }             }         }

# BOOTSTRAP: Si el historial de tendencias está vacío/disperso, reconstruir a partir de datos históricos         if ($historyData.Count -lt 2 -and ($leafName -match '^Aggregation_\d{8}' -or $leafName -eq 'Aggregation_Current')) {             Write-Host " Arranque del historial de tendencias de datos históricos..." -ForegroundColor Yellow             $dailyData = @{}                          # Origen 1: Csv de resumen dentro de la carpeta actual (Aggregation_Current mantiene todos los CSV de resumen)             $localSummaries = Get-ChildItem $OutputPath -Filter "SecureBoot_Summary_*.csv" -EA SilentlyContinue | nombre de Sort-Object             foreach ($summCsv en $localSummaries) {                 prueba {                     $summ = Import-Csv $summCsv.FullName | Select-Object - Primeros 1                     if ($summ. TotalDevices -and [int]$summ. TotalDevices -gt 0 - y $summ. ReportGeneratedAt) {                         $dateStr = ([datetime]$summ. ReportGeneratedAt). ToString("aaaa-MM-dd")                         $updated = if ($summ. Actualizado) { [int]$summ. Actualizado } más { 0 }                         $notUpd = if ($summ. NotUptodate) { [int]$summ. NotUptodate } else { [int]$summ. TotalDevices - $updated }                         $dailyData[$dateStr] = [PSCustomObject]@{                             Fecha = $dateStr; Total = [int]$summ. TotalDevices; Actualizado = $updated; NotUpdated = $notUpd                             NeedsReboot = 0; Errores = 0; ActionRequired = if ($summ. ActionRequired) { [int]$summ. AcciónObligatoria } más { 0 }                         }                     }                 } captura { }             }                          # Origen 2: Antiguas carpetas de Aggregation_* con marca de tiempo (heredadas, si siguen existiendo)             $aggFolders = Get-ChildItem $parentDir -Directory -Filter "Aggregation_*" -EA SilentlyContinue |                 Where-Object { $_. Name -match '^Aggregation_\d{8}' } |                 nombre de Sort-Object             foreach ($folder en $aggFolders) {                 $summCsv = Get-ChildItem $folder. FullName -Filter "SecureBoot_Summary_*.csv" -EA SilentlyContinue | Select-Object - Primeros 1                 if ($summCsv) {                     prueba {                         $summ = Import-Csv $summCsv.FullName | Select-Object - Primeros 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. Actualizado) { [int]$summ. Actualizado } más { 0 }                             $notUpd = if ($summ. NotUptodate) { [int]$summ. NotUptodate } else { [int]$summ. TotalDevices - $updated }                             $dailyData[$dateStr] = [PSCustomObject]@{                                 Fecha = $dateStr; Total = [int]$summ. TotalDevices; Actualizado = $updated; NotUpdated = $notUpd                                 NeedsReboot = 0; Errores = 0; ActionRequired = if ($summ. ActionRequired) { [int]$summ. AcciónObligatoria } más { 0 }                             }                         }                     } captura { }                 }             }                          # Fuente 3: RolloutState.json WaveHistory (tiene marcas de tiempo per-wave desde el día 1)             # Esto proporciona puntos de datos de línea base incluso cuando no existen carpetas de agregación antiguas             $rolloutStatePaths = @(                 (Join-Path $parentDir "RolloutState\RolloutState.json"),                 (Join-Path $OutputPath "RolloutState\RolloutState.json")             )             foreach ($rsPath en $rolloutStatePaths) {                 if ($rsPath ruta de prueba) {                     prueba {                         $rsData = Get-Content $rsPath -Raw | ConvertFrom-Json                         if ($rsData.WaveHistory) {                             # Usar fechas de inicio de onda como puntos de datos de tendencia                             # Calcular dispositivos acumulados dirigidos a cada tanda                             $cumulativeTargeted = 0                             foreach ($wave en $rsData.WaveHistory) {                                 si ($wave. StartedAt y $wave. DeviceCount) {                                     $waveDate = ([datetime]$wave. StartedAt). ToString("aaaa-MM-dd")                                     $cumulativeTargeted += [int]$wave. DeviceCount                                     if (-not $dailyData.ContainsKey($waveDate)) {                                         # Aproximado: a la hora de inicio de la onda, solo se actualizaron los dispositivos de ondas anteriores                                         $dailyData[$waveDate] = [PSCustomObject]@{                                             Fecha = $waveDate; Total = $c.Total; Actualizado = [math]::Max(0, $cumulativeTargeted - [int]$wave. DeviceCount)                                             NotUpdated = $c.Total - [math]::Max(0, $cumulativeTargeted - [int]$wave. DeviceCount)                                             NeedsReboot = 0; Errores = 0; ActionRequired = 0                                         }                                     }                                 }                             }                         }                     } captura { }                     break # Usar first found                 }             }

if ($dailyData.Count -gt 0) {                 $historyData = @($dailyData.GetEnumerator() | clave Sort-Object | ForEach-Object { $_. Valor })                 Write-Host " Bootstrapped $($historyData.Count) puntos de datos de resúmenes históricos" -ForegroundColor Green             }         }

# Agregar punto de datos actual (deduplicación por día: mantener lo último por día)         $todayKey = (Get-Date). ToString("aaaa-MM-dd")         $existingToday = $historyData | Where-Object { "$($_. Date)" -like "$todayKey*" }         if ($existingToday) {             # Reemplazar la entrada de hoy             $historyData = @($historyData | Where-Object { "$($_. Fecha)" (no similar a "$todayKey*" })         }         $historyData += [PSCustomObject]@{             Date = $todayKey             Total = $c.Total             Actualizado = $c.Actualizado             NotUpdated = $stNotUptodate             NeedsReboot = $c.NeedsReboot             Errors = $c.WithErrors             ActionRequired = $c.ActionReq         }         # Quitar los puntos de datos erróneos (0 total) y mantener los últimos 90         $historyData = @($historyData | Where-Object { [int]$_. Total -gt 0 })         # Sin límite: los datos de tendencia son ~100 bytes/entrada, un año completo = ~36 KB         $historyData | ConvertTo-Json -Profundidad 3 | Set-Content $historyPath -Encoding UTF8         Write-Host " Historial de tendencias: $($historyData.Count) puntos de datos" -ForegroundColor DarkGray                  # Crear datos de gráfico de tendencias para HTML         $trendLabels = ($historyData | ForEach-Object { "'$($_. Date)'" }) -join ","         $trendUpdated = ($historyData | ForEach-Object { $_. Actualizado }) -join ","         $trendNotUpdated = ($historyData | ForEach-Object { $_. NotUpdated }) -join ","         $trendTotal = ($historyData | ForEach-Object { $_. Total }) -join ","         # Proyección: extender línea de tendencia utilizando la duplicación exponencial (2,4,8,16...)         # Obtiene el tamaño de onda y el período de observación de datos reales del historial de tendencias.# - Tamaño de onda = mayor aumento de un solo período visto en la historia (la ola más reciente implementada)         # - Días de observación = promedio de días del calendario entre los puntos de datos de tendencia (con qué frecuencia se ejecuta)         # A continuación, duplica el tamaño de onda cada período, haciendo coincidir la estrategia de crecimiento de 2x del organizador.$projLabels = ""; $projUpdated = ""; $projNotUpdated = ""; $hasProjection = $false         if ($historyData.Count -ge 2) {             $lastUpdated = $c.Updated             $remaining = $stNotUptodate # Solo los dispositivos que no están actualizados con SB-ON (excluye SecureBoot OFF)             $projDates = @(); $projValues = @(); $projNotUpdValues = @()             $projDate = Get-Date

# Derivar el tamaño de onda y el período de observación del historial de tendencias             $increments = @()             $dayGaps = @()             for ($hi = 1; $hi -lt $historyData.Count; $hi++) {                 $inc = $historyData[$hi]. Actualizado - $historyData[$hi-1]. Actualizado                 if ($inc -gt 0) { $increments += $inc }                 prueba {                     $d 1 = [datetime]::P arse($historyData[$hi-1]. Fecha)                     $d 2 = [datetime]::P arse($historyData[$hi]. Fecha)                     $gap = ($d 2 - $d 1). TotalDays                     if ($gap -gt 0) { $dayGaps += $gap }                 } captura de {}             }             # Tamaño de onda = incremento positivo más reciente (onda actual), retroceso al promedio, mínimo 2             $waveSize = if ($increments. Contar -gt 0) {                 [matemáticas]:Máx(2, $increments[-1])             } más { 2 }             # Período de observación = intervalo medio entre puntos de datos (días calendario por ola), mínimo 1             $waveDays = if ($dayGaps.Count -gt 0) {                 [math]::Max(1, [math]::Round(($dayGaps | Measure-Object -Average). Promedio, 0))             } más { 1 }

            Write-Host " Proyección: waveSize=$waveSize (a partir del último incremento), waveDays=$waveDays (avg gap from history)" -ForegroundColor DarkGray

$dayCounter = 0             # Proyectar hasta que todos los dispositivos se actualicen o 365 días como máximo             for ($pi = 1; $pi -le 365; $pi++) {                 $projDate = $projDate.AddDays(1)                 $dayCounter++                 # En cada límite del período de observación, implementa una onda y luego                 if ($dayCounter -ge $waveDays) {                     $devicesThisWave = [matemáticas]::Mín($waveSize, $remaining)                     $lastUpdated += $devicesThisWave                     $remaining -= $devicesThisWave                     if ($lastUpdated -gt ($c.Updated + $stNotUptodate)) { $lastUpdated = $c.Updated + $stNotUptodate; $remaining = 0 }                     # Tamaño de onda doble para el siguiente período (estrategia organizadora 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 " Proyección: necesita al menos 2 puntos de datos de tendencia para derivar los intervalos de onda" -ForegroundColor DarkGray         }         # Crear cadenas de datos de gráfico combinadas para la cadena here         $allChartLabels = if ($hasProjection) { "$trendLabels,$projLabels" } else { $trendLabels }         $projDataJS = if ($hasProjection) { $projUpdated } else { "" }         $projNotUpdJS = if ($hasProjection) { $projNotUpdated } else { "" }         $histCount = ($historyData | Measure-Object). Contar         $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Descendente | ForEach-Object {             @{ name=$_. Tecla; total=$_. Value.Total; updated=$_. Value.Updated; highConf=$_. Value.HighConf; actionReq=$_. Value.ActionReq }         } | ConvertTo-Json -Profundidad 3 | Set-Content (Join-Path $dataDir "manufacturers.json") -Encoding UTF8         # Convertir archivos de datos JSON en CSV para descargas de Excel legibles para usuarios humanos         Write-Host "Convirtiendo datos del dispositivo en CSV para descarga de Excel..." -ForegroundColor Gray         foreach ($dfName en $stDeviceFiles) {             $jsonFile = Join-Path $dataDir "$dfName.json"             $csvFile = Join-Path $OutputPath "timestamp.csv de SecureBoot_${dfName}_$ "             if ($jsonFile ruta de prueba) {                 prueba {                     $jsonData = Get-Content $jsonFile -Raw | ConvertFrom-Json                     if ($jsonData.Count -gt 0) {                         # Incluir columnas adicionales para 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 -> filas $($jsonData.Count) -> CSV" -ForegroundColor DarkGray                     }                 } captura { Write-Host " $dfName - omitido" -ForegroundColor DarkYellow }             }         }         # Generar panel HTML autocontenido         $htmlPath = Join-Path $OutputPath "timestamp.html SecureBoot_Dashboard_$"         Write-Host "Generating self-contained HTML dashboard..." -ForegroundColor Yellow         # VELOCITY PROJECTION: Calcular a partir del historial de exploración o del resumen anterior         $stDeadline = [datetime]"2026-06-24" # Expiración del certificado KEK         $stDaysToDeadline = [math]::Max(0, ($stDeadline - (Get-Date)). Días)         $stDevicesPerDay = 0         $stProjectedDate = $null         $stVelocitySource = "N/A"         $stWorkingDays = 0         $stCalendarDays = 0         # Prueba primero el historial de tendencias (ligero, ya mantenido por agregador, reemplaza ScanHistory.json de aumento)         if ($historyData.Count -ge 2) {             $validHistory = @($historyData | Where-Object { [int]$_. Total -gt 0 -y [int]$_. Actualizado -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. Actualizado - [int]$prev. Actualizado                     if ($updDiff -gt 0) {                         $stDevicesPerDay = [math]::Round($updDiff / $daysDiff, 0)                         $stVelocitySource = "TrendHistory"                     }                 }             }         }         # Prueba el resumen de implementación del organizador (tiene velocidad calculada previamente)         if ($stVelocitySource -eq "N/A" -and $RolloutSummaryPath -and (Test-Path $RolloutSummaryPath)) {             prueba {                 $rolloutSummary = Get-Content $RolloutSummaryPath -Raw | ConvertFrom-Json                 if ($rolloutSummary.DevicesPerDay -and [double]$rolloutSummary.DevicesPerDay -gt 0) {                     $stDevicesPerDay = [math]::Round([double]$rolloutSummary.DevicesPerDay, 1)                     $stVelocitySource = "Organizador"                     if ($rolloutSummary.ProjectedCompletionDate) {                         $stProjectedDate = $rolloutSummary.ProjectedCompletionDate                     }                     if ($rolloutSummary.WorkingDaysRemaining) { $stWorkingDays = [int]$rolloutSummary.WorkingDaysRemaining }                     if ($rolloutSummary.CalendarDaysRemaining) { $stCalendarDays = [int]$rolloutSummary.CalendarDaysRemaining }                 }             } captura { }         }         # Reserva: prueba el CSV de resumen anterior (buscar en la carpeta actual Y en las carpetas de agregación principales/hermanos)         if ($stVelocitySource -eq "N/A") {             $searchPaths = @(                 ($OutputPath "SecureBoot_Summary_*.csv")             )             # También busca en carpetas de agregación del mismo nivel (el organizador crea nuevas carpetas cada ejecución)             $parentPath = Split-Path $OutputPath -Parent             if ($parentPath) {                 $searchPaths += ($parentPath de ruta de unión "Aggregation_*\SecureBoot_Summary_*.csv")                 $searchPaths += ($parentPath "SecureBoot_Summary_*.csv")             }             $prevSummary = $searchPaths | ForEach-Object { Get-ChildItem $_ -EA SilentlyContinue } | Sort-Object LastWriteTime -Descending | Select-Object -First 1             if ($prevSummary) {                 prueba {                     $prevStats = Get-Content $prevSummary.FullName | ConvertirFrom-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"                         }                     }                 } captura { }             }         }         # Fallback: calcular la velocidad a partir de un intervalo completo del historial de tendencias (primer punto de datos o punto de datos más reciente)         if ($stVelocitySource -eq "N/A" -and $historyData.Count -ge 2) {             $validHistory = @($historyData | Where-Object { [int]$_. Total -gt 0 -y [int]$_. Actualizado -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. Actualizado - [int]$first. Actualizado                     if ($updDiff -gt 0) {                         $stDevicesPerDay = [math]::Round($updDiff / $daysDiff, 1)                         $stVelocitySource = "TrendHistory"                     }                 }             }         }         # Calcular la proyección mediante la duplicación exponencial (coherente con el gráfico de tendencia)         # Volver a usar los datos de proyección ya calculados para el gráfico si están disponibles         if ($hasProjection -and $projDates.Count -gt 0) {             # Usar la última fecha proyectada (cuando se actualizan todos los dispositivos)             $lastProjDateStr = $projDates[-1] -replace "'", ""             $stProjectedDate = ([datetime]::P arse($lastProjDateStr)). ToString("MMM dd, aaaa")             $stCalendarDays = ([datetime]::P arse($lastProjDateStr) - (Get-Date)). Días             $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 -y $stNotUptodate -gt 0) {             # Reserva: proyección lineal si no hay datos exponenciales disponibles             $daysNeeded = [matemáticas]::Techo($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++ }             }         }         # Crear html de velocidad         $velocityHtml = if ($stDevicesPerDay -gt 0) {             "<div><strong>&#128640; Devices/Day:</strong> $($stDevicesPerDay.ToString('N0')) (source: $stVelocitySource)</div>" +             "<div><strong>&#128197; Finalización proyectada:</strong> $stProjectedDate" +             $(if ($stProjectedDate -and [datetime]::P arse($stProjectedDate) -gt $stDeadline) { " <span style='color:#dc3545; font-weight:bold'>&#9888; FECHA LÍMITE PASADA</span>" } else { " <span style='color:#28a745'>&#10003; Before deadline</span>" }) +             "</div>" +             "<div><strong>&#128336; Días laborables:</strong> $stWorkingDays | <días>Calendar strong:</strong> $stCalendarDays</div>" +             "<div style='font-size:.8em; color:#888'>Fecha límite: 24 de junio de 2026 (expiración del certificado KEK) | Días restantes: $stDaysToDeadline</div>"         } else {             "<div style='padding:8px; fondo:#fff3cd; border-radius:4px; border-left:3px solid #ffc107'>" +             "<>&#128197 strong; Finalización proyectada:</strong> Datos insuficientes para el cálculo de velocidad.                                                                                  " +             "Ejecuta la agregación al menos dos veces con cambios de datos para establecer una tasa.<br/>" +             "<fecha límite>fuerte:</strong> 24 de junio de 2026 (expiración del certificado KEK) | <>días restantes:</strong> $stDaysToDeadline</div>"         }                  Cuenta atrás de expiración del certificado         $certToday = Get-Date         $certKekExpiry = [datetime]"2026-06-24"         $certUefiExpiry = [datetime]"2026-06-27"         $certPcaExpiry = [datetime]"2026-10-19"         $daysToKek = [math]::Max(0, ($certKekExpiry - $certToday). Días)         $daysToUefi = [math]::Max(0, ($certUefiExpiry - $certToday). Días)         $daysToPca = [math]::Max(0, ($certPcaExpiry - $certToday). Días)         $certUrgency = if ($daysToKek -lt 30) { '#dc3545' } elseif ($daysToKek -lt 90) { '#fd7e14' } else { '#28a745' }                  # Helper: leer registros de JSON, compilar el resumen del bucket + primeras filas del dispositivo N         $maxInlineRows = 200         Build-InlineTable de función {             parámetro([cadena]$JsonPath, [int]$MaxRows = 200, [cadena]$CsvFileName = "")             $bucketSummary = ""             $deviceRows = ""             $totalCount = 0             if ($JsonPath ruta de prueba) {                 prueba {                     $data = Get-Content $JsonPath -Raw | ConvertFrom-Json                     $totalCount = $data. Contar                                          # RESUMEN DEL BUCKET: Agrupar por BucketId, mostrar recuentos por cubo con Actualizado a partir de estadísticas de cubo global                     if ($totalCount -gt 0) {                         $buckets = $data | Group-Object BucketId | Sort-Object Contar: descendente                         $bucketSummary = "><2 h3 style='font-size:.95em; color:#333; margin:10px 0 5px'><3 By Hardware Bucket ($($buckets. Contar) cubos)><4 /h3>"                         $bucketSummary += "><6 div style='max-height:300px; overflow-y:auto; margin-bottom:15px'><tabla><thead><tr><th><5 BucketID><6 /th><th style='text-align:right'>Total</th><th style='text-align:right; color:#28a745'>Actualizado</th><th style='text-align:right; color:#dc3545 >no actualizado</th><><1 Manufacturer><2 /th></tr></thead><tbody>"                         foreach ($b en $buckets) {                             $bid = if ($b.Name) { $b.Name } else { "(empty)" }                             $mfr = ($b.Group | Select-Object -First 1). WMI_Manufacturer                             # Obtener Recuento actualizado de estadísticas de cubo globales (todos los dispositivos de este bucket en todo el conjunto de datos)                             $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>"                     }                                          # DETALLE DEL DISPOSITIVO: Primera N filas como lista plana                     $slice = $data | Select-Object- Primera $MaxRows                     foreach ($d en $slice) {                         $conf = $d.ConfidenceLevel                         $confBadge = if ($conf -match "High") { '<span class="badge-success">High Conf><2 /span>' }                                      elseif ($conf -match "Not Sup") { '<span class="badge-danger">not supported><6 /span>' }                                      elseif ($conf -match "Under") { '<span class="badge-info">Under Obs><0 /span>' }                                      elseif ($conf -match "Paused") { '<span class="badge-warning">Paused><4 /span>' }                                      else { '<span class="badge-warning">action req><8 /span>' }                         $statusBadge = if ($d.IsUpdated) { '><00 span class="badge-success"><01 Actualizado</span>' }                                        elseif ($d.UEFICA2023Error) { '><04 span class="badge-danger"><05 Error</span>' }                                        else { '><08 span class="badge-warning"><09 pendiente><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"                     }                 } captura { }             }             if ($totalCount -eq 0) {                 devolver "><44 div style='padding:20px; color:#888; font-style:italic'><45 No hay dispositivos en esta categoría.><46 /div>"             }             $showing = [matemáticas]::Mín($MaxRows, $totalCount)             $header = "><48 div style='margin:5px 0; tamaño de fuente:.85em; color:#666'><49 Total: dispositivos $($totalCount.ToString("N0")) "             if ($CsvFileName) { $header += " | ><50 a href='$CsvFileName' style='color:#1a237e; font-weight:bold'>&#128196; Descargar csv completo para Excel><3 /a>" }             $header += "><55 /div>"             $deviceHeader = "><57 h3 style='font-size:.95em; color:#333; margin:10px 0 5px'><58 Device Details (mostrando la primera $showing)><59 /h3>"             $deviceTable = "><61 div style='max-height:500px; tabla overflow-y:auto'><><thead><tr><th><0 HostName><1 /th><th><4 Manufacturer><5 /th><th><8 Model><9 /th><th><2 Confidence><3 /th><th><6 estado><7 /th><th><0 Error><1 /th><th><4 BucketId><5 /th></tr></thead><tbody><2 $deviceRows><3 /tbody></table></div>"             devolver "$header$bucketSummary$deviceHeader$deviceTable"         }                  # Crea tablas alineadas a partir de los archivos JSON que ya están en disco, vinculando a 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"         # Tabla personalizada para la actualización pendiente: incluye las columnas UEFICA2023Status y UEFICA2023Error         $tblUpdatePending = ""         $upJsonPath = Join-Path $dataDir "update_pending.json"         if ($upJsonPath ruta de prueba) {             prueba {                 $upData = Get-Content $upJsonPath -Raw | ConvertFrom-Json                 $upCount = $upData.Count                 if ($upCount -gt 0) {                     $upHeader = "<div style='margin:5px 0; tamaño de fuente:.85em; color:#666'>Total: dispositivos $($upCount.ToString("N0")) | <a href='SecureBoot_update_pending_$timestamp.csv' style='color:#1a237e; font-weight:bold'>&#128196; Descargar el archivo CSV completo para Excel><4 /a></div>"                     $upRows = ""                     $upSlice = $upData | Select-Object: primer $maxInlineRows                     foreach ($d en $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-success">Sí><8 /span>' } else { '-' }                         $upRows += "<tr><td><3 $($d.HostName)</td><td><7 $($d.WMI_Manufacturer)</td><td><1 $($d.WMI_Model)</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 = [matemáticas]::Mín($maxInlineRows, $upCount)                     $upDevHeader = "<h3 style='font-size:.95em; color:#333; margin:10px 0 5px'>Device Details (mostrando la primera $upShowing)</h3>"                     $upTable = "<div style='max-height:500px; tabla overflow-y:auto'><><thead><tr><th><9 HostName><0 /th><th><3 Manufacturer><4 /th><th><7 Model><8 /th><th><1 UEFICA2023Status><2 /th><th><5 UEFICA2023Error><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'>No hay dispositivos en esta categoría.</div>"                 }             } captura {                 $tblUpdatePending = "<div style='padding:20px; color:#888; font-style:italic'>No hay dispositivos en esta categoría.</div>"             }         } else {             $tblUpdatePending = "<div style='padding:20px; color:#888; font-style:italic'>No hay dispositivos en esta categoría.</div>"         }                  Cuenta atrás de expiración del certificado         $certToday = Get-Date         $certKekExpiry = [datetime]"2026-06-24"         $certUefiExpiry = [datetime]"2026-06-27"         $certPcaExpiry = [datetime]"2026-10-19"         $daysToKek = [math]::Max(0, ($certKekExpiry - $certToday). Días)         $daysToUefi = [math]::Max(0, ($certUefiExpiry - $certToday). Días)         $daysToPca = [math]::Max(0, ($certPcaExpiry - $certToday). Días)         $certUrgency = if ($daysToKek -lt 30) { '#dc3545' } elseif ($daysToKek -lt 90) { '#fd7e14' } else { '#28a745' }                  # Compila los datos del gráfico del fabricante en línea (los 10 mejores según el recuento de dispositivos)         $mfrSorted = $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Descendente | Select-Object - Primeros 10         $mfrChartTitle = if ($stMfrCounts.Count -le 10) { "By Manufacturer" } else { "Top 10 Manufacturers" }         $mfrLabels = ($mfrSorted | ForEach-Object { "'$($_. Key)'" }) -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 ","                  # Crear tabla del fabricante         $mfrTableRows = ""         $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Descendente | 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 = @" <!> html DOCTYPE <html lang="en"> ><3 de cabeza de < <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <título><9 panel de estado de certificado de arranque seguro><0 /title><1 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script><5 ><7 de estilo < *{box-sizing:border-box; margen:0; relleno:0} body{font-family:'Segoe UI',Tahoma,sans-serif; fondo:#f0f2f5; color:#333} .header{background:linear-gradient(135deg,#1a237e,#0d47a1); color:#fff; padding:20px 30px} .header h1{font-size:1.6em; margin-bottom:5px} .header .meta{font-size:.85em; opacidad:.9} .container{max-width:1400px; margen:0 automático; padding:20px} .cards{display:grid; grid-template-columns:repeat(autorrellenar,minmax(170px,1fr)); gap:12px; margin:20px 0} .card{background:#fff; border-radius:10px; padding:15px; box-shadow:0 2px 8px rgba(0,0,0,.08); border-left:4px solid #ccc;transition:transform .2s} .card:hover{transform:translateY(-2px); box-shadow:0 4px 15px rgba(0,0,0,.12)} .card .value{font-size:1.8em; peso de fuente:700} .card .label{font-size:.8em; color:#666; margin-top:4px} .card .pct{font-size:.75em; color:#888} .section{background:#fff; border-radius:10px; padding: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:puntero; 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; padding:20px; box-shadow:0 2px 8px rgba(0,0,0,.08)} tabla{width:100%; borde-contraer:contraer; tamaño de fuente:.85em} th{background:#e8eaf6; padding:8px 10px; alinear texto:a la izquierda; position:sticky; superior:0; índice z:1} td{padding:6px 10px; borde inferior:1px de #eee} tr:hover{background:#f5f5f5} .badge{display:inline-block; padding:2px 8px;border-radius:10px; font-size:.75em; peso de fuente: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; padding:20px; color:#999; tamaño de fuente:.8em} a{color:#1a237e} </><9 de estilo ></head <> de cuerpo <div class="header">     Panel de estado de certificado de arranque seguro <h1></h1>     <div class="meta">Generado: $($stats. ReportGeneratedAt) | Total de dispositivos: $($c.Total.ToString("N0")) | Cubos únicos: $($stAllBuckets.Count)</div><3 </div><5 <div class="container">

tarjetas KPI de<!--: seleccionable, vinculadas a secciones > <div class="cards">     <a class="card" href="#s-nu" onclick="openSection('d-nu')" style="border-left-color:#dc3545; text-decoration:none; position:relative"><div style="position:absolute; top:8px; derecha:8px; fondo:#dc3545; color:#fff; padding: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)% - NEEDS ACTION><0 /div></a><3     <a class="card" href="#s-upd" onclick="openSection('d-upd')" style="border-left-color:#28a745; text-decoration:none; position:relative"><div style="position:absolute; top:8px; derecha:8px; fondo:#28a745; color:#fff; padding: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">Updated><6 /div><div class="pct">$($stats. PercentCertUpdated)%</div></a><3     <un class="card" href="#s-sboff" onclick="openSection('d-sboff')" style="border-left-color:#6c757d; text-decoration:none; position:relative"><div style="position:absolute; top:8px; derecha:8px; fondo:#6c757d; color:#fff; padding: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})% - Fuera del ámbito><0 /div></a><3     <a class="card" 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">Needs Reboot><2 /div><div class="pct ">$(if($c.Total -gt 0){[math]::Round(($c.NeedsReboot/$c.Total)*100,1)}else{0})%: esperando reinicio><6 /div></a><9     <a class="card" 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">Actualización pendiente</div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.UpdatePending/$c.Total)*100,1)}else{0})% - Directiva/WinCS aplicada, esperando actualización><2 /div></a><5     <a class="card" 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="card" 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">><20 /div><div class="pct">$($stats. PercentHighConfidence)%: seguro para el lanzamiento><24 /div></a><27     <a class="card" 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="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="card" 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">Acción Necesaria><2 /div><div class="pct">$($stats. PercentActionRequired)%: debe probar><6 /div></a><9     <a class="card" 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)%: similar al error><2 /div></a><5     <a class="card" 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><div class=""pct">$(if($c.Total -gt 0){[math]::Round(($c.TaskDisabled/$c.Total)*100,1)}else{0})% - Bloqueado><8 /div></a><91     <a class="card" 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. Pausado</div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.TempPaused/$c.Total)*100,1)}else{0})%</div></a>     <a class="card" 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">Known Issues><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="card" 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">FALTA KEK</div><clase div="pct">$(if($c.Total -gt 0){[math]::Round(($c.WithMissingKEK/$c.Total)*100,1)}else{0})%</div></a>     <un class="card" 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)% - Errores UEFI</div></a>     ><6 un class="card" 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. Errores</div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.TempFailures/$c.Total)*100,1)}else{0})%</div></a>     <a class="card" 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">Not Supported><6 /div><div class="pct ">$(if($c.Total -gt 0){[math]::Round(($c.PermFailures/$c.Total)*100,1)}else{0})%</div></a><3 </div>

<!-- expiración del certificado de velocidad de & de implementación --> <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; Deployment Velocity</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">dispositivos actualizados de $($c.Total.ToString("N0"))</div>         <div style="margin:10px 0; fondo:#e8eaf6; height:20px; border-radius:10px; overflow:hidden"><div style="background:#28a745; altura:100%; ancho:$($stats. PercentCertUpdated)%; border-radius:10px"></div></div>         <div style="font-size:.8em; color:#888">$($stats. PercentCertUpdated)% completado</div>         <div style="margin-top:10px; padding:10px; fondo:#f8f9fa; border-radius:8px; font-size:.85em">             <dispositivos div><strong>Remaining:</strong> $($stNotUptodate.ToString("N0")) necesitan acción</div>             <div><fuerte bloqueo de>:</strong> $($c.WithErrors + $c.PermFailures + $c.TaskDisabledNotUpdated) dispositivos (errores + permanente + tarea deshabilitada)</div>             <dispositivos div><strong>Safe to deploy:</strong> $($stSafeList.ToString("N0")) (mismo bucket que se ha realizado correctamente)</div>             $velocityHtml         </div>     </div> </div> <div class="section" style="margin:0; border-left:4px solid #dc3545">     <h2 style="color:#dc3545">&#9888;</h2>     <div class="section-body open">         <div style="display:flex; gap:15px; margin-top:10px">             <div style="text-align:center; padding:15px; border-radius:8px; ancho mínimo:120px; fondo:degradado lineal(135deg,#fff5f5,#ffe0e0); borde:2px #dc3545 sólido; 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; alto de línea:1"><9 $daysToKek</div>                 ><2 div style="font-size:.8em; color:#721c24"><3 días (24 de junio de 2026)><4 /div>             ><6 /div>             ><8 div style="text-align:center; padding:15px; border-radius:8px; ancho mínimo:120px; fondo:degradado lineal(135deg,#fffef5,#fff3cd); borde:2px #ffc107 sólido; 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; alto de línea:1; margin:5px 0">$daysToUefi</div>                 <div style="font-size:.8em; color:#856404">días (27 de junio de 2026)</div>             </div>             <div style="text-align:center; padding:15px; border-radius:8px; ancho mínimo:120px; fondo:degradado lineal(135deg,#f0f8ff,#d4edff); borde:2px #0078d4 sólido; flex:1">                 <div style="font-size:.65em; color:#0078d4; text-transform:uppercase; font-weight:bold">PCA de Windows</div>                 <div id="daysPca" style="font-size:2.2em; font-weight:700; color:#0078d4; alto de línea:1; margin:5px 0">$daysToPca><2 /div><3                 <div style="font-size:.8em; color:#0078d4">días (19 de octubre de 2026)</div><7             </div><9         </div><1         <div style="margin-top:15px; padding:10px; fondo:#f8d7da; border-radius:8px; tamaño de fuente:.85em; border-left:4px solid #dc3545">             <strong>&#9888; CRITICAL:</strong> Todos los dispositivos deben actualizarse antes de la expiración del certificado. Los dispositivos no actualizados antes de la fecha límite no pueden aplicar actualizaciones de seguridad futuras para El Administrador de arranque y el arranque seguro después de expirar.</div>    >/div < </div> </div>

gráficos<!-- --> <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) { "Gráfico de tendencias históricas <!-- --> <div class='section'>     <h2 onclick='"toggle('d-trend')'">&#128200; Progreso de actualización con el tiempo <un 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'>Líneas continuas = datos reales$(if ($historyData.Count -ge 2) { " | Línea discontinua = proyectada (duplicación exponencial: 2&#x2192;4&#x2192;8&#x2192;16... dispositivos por onda)" } else { " | Ejecute la agregación de nuevo mañana para ver las líneas de tendencia y la proyección" })</div>     </div> </div>" })

Descargas de<!-- CSV : > <div class="section">     <h2 onclick="toggle('dl-csv')">&#128229; Descargar datos completos (CSV para Excel) <a class="top-link" href="#">top</a></h2><2     <div id="dl-csv" class="section-body open" style="display:flex; flex-wrap:wrap; gap:5px">         <a href="SecureBoot_not_updated_$timestamp.csv" style="display:inline-block; fondo:#dc3545; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">No actualizado ($($stNotUptodate.ToString("N0")))</a><8         <a href="SecureBoot_errors_$timestamp.csv" style="display:inline-block; fondo:#dc3545; color:#fff; padding: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; fondo:#fd7e14; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">Acción necesaria ($($c.ActionReq.ToString("N0")))</a>         <a href="SecureBoot_known_issues_$timestamp.csv" style="display:inline-block; fondo:#dc3545; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">Problemas conocidos ($($c.WithKnownIssues.ToString("N0")))</a>         <a href="SecureBoot_task_disabled_$timestamp.csv" style="display:inline-block; fondo:#dc3545; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">Tarea deshabilitada ($($c.TaskDisabled.ToString("N0")))</a>         <a href="SecureBoot_updated_devices_$timestamp.csv" style="display:inline-block; fondo:#28a745; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">Updated ($($c.Updated.ToString("N0")))</a>         <a href="SecureBoot_Summary_$timestamp.csv" style="display:inline-block; fondo:#6c757d; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">Resumen</a>         <div style="width:100%; font-size:.75em; color:#888; margin-top:5px">archivos CSV abiertos en Excel. Disponible cuando se hospeda en servidor web.</div>     </div> </div>

<!-- desglose del fabricante: > <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">     <tabla><><><><1 Manufacturer><2 /th><th><5 Total><6 /th><th><9 Updated><9><0 /th><th><3 High Confidence><4 /th><th><7 Action Required><8 /th></tr></thead><3     <><5 $mfrTableRows><6 /tbody></table><9     </div><1 </div>

<!-- secciones del dispositivo (primeras 200 en línea + descarga CSV) --> <div class="section" id="s-err">     <h2 onclick="toggle('d-err')">&#128308; Dispositivos con errores ($($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; Problemas conocidos ($($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; FALTA KEK: evento 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; Acción Obligatoria ($($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; En Observación ($($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; No actualizado ($($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; Tarea deshabilitada ($($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 </div><7 <div class="section" id="s-tf">     <h2 onclick="toggle('d-tf')" style="color:#dc3545">&#128308; Los errores temporales ($($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; Errores permanentes o no compatibles ($($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; Actualización pendiente ($($c.UpdatePending.ToString("N0"))): Directiva/WinCS aplicada, esperando actualización <un class="top-link" href="#">&#8593; top</a></h2>     <div id="d-upd-pend" class="section-body"><p style="color:#666; margin-bottom:10px">Dispositivos en los que se aplica la clave AvailableUpdatesPolicy o WinCS, pero UEFICA2023Status sigue siendo NotStarted, InProgress o null.</p>$tblUpdatePending</div> </div> <div class="section" id="s-rip">     <h2 onclick="toggle('d-rip')" style="color:#17a2b8">&#128309; Implementación en curso ($($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"))): Fuera del ámbito <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; Dispositivos actualizados ($($c.Updated.ToString("N0"))) <a 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; Actualizado: es necesario reiniciar ($($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">Panel de implementación de certificados de arranque seguro | Generado $($stats. ReportGeneratedAt) | StreamingMode | Memoria máxima: ${stPeakMemMB} MB</div> </div><!-- /container -->

>de scripts de< function toggle(id){var e=document.getElementById(id); e.classList.toggle('open')} función openSection(id){var e=document.getElementById(id); if(e&&!e.classList.contains('open')){e.classList.add('open')}} nuevo Chart(document.getElementById('deployChart'),{type:'doughnut',data:{labels:['Updated','Update Pending','High Confidence','Under Observation','Action Required','Temp. Paused','Not Supported','SecureBoot OFF','With Errors'],datasets:[{data:[$($c.Updated),$($c.UpdatePending),$($c.HighConf),$($c.UnderObs),$($c.ActionReq),$($c.TempPaused),$($c.NotSupported),$($c.SBOff),$($c.NotSupported),$($c.SBOff);$($c $c.NotSupported)..WithErrors)],backgroundColor:['#28a745','#6f42c1','#20c997','#17a2b8','#fd7e14','#6c757d','#721c24','#adb5bd','#dc3545']}]},opciones:{responsive:true,plugins:{legend:{position:'right',labels:{font:{size:11}}}}}}); nuevo Chart(document.getElementById('mfrChart'),{type:'bar',data:{labels:[$mfrLabels],datasets:[{label:'Updated',data:[$mfrUpdated],backgroundColor:'#28a745'},{label:'Update Pending',data:[$mfrUpdatePending],backgroundColor:'#6f42c1'},{label:'Confianza alta',datos:[$mfrHighConf],backgroundColor:'#20c997'},{label:'Under Observation',data:[$mfrUnderObs],backgroundColor:'#17a2b8'},{label:'Acción requerida',datos:[$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'}}}); Gráfico de tendencias histórico if (document.getElementById('trendChart')) { var allLabels = [$allChartLabels]; var actualUpdated = [$trendUpdated]; var actualNotUpdated = [$trendNotUpdated]; var realTotal = [$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',datos:paddedTotal,borderColor:'#6c757d',borderDash:[5,5],fill:false,tension:0,pointRadius:0,borderWidth:1} ]; if (projLen > 0) {     datasets.push({label:'Projected Updated (2x doubling)',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'}); } nuevo 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'}}}); } Cuenta atrás dinámica (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))         # Mantenga siempre estable una copia "Más reciente" para que los administradores no necesiten realizar un seguimiento de las marcas de tiempo         $latestPath = Join-Path $OutputPath "SecureBoot_Dashboard_Latest.html"         Copy-Item $htmlPath $latestPath - Fuerza         $stTotal = $streamSw.Elapsed.TotalSeconds         # Guardar manifiesto de archivo para el modo incremental (detección rápida de sin cambios en la siguiente ejecución)         if ($IncrementalMode o $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 "Manifiesto de guardar archivo para el modo incremental..." -ForegroundColor Gray             foreach ($jf en $jsonFiles) {                 $stNewManifest[$jf. FullName.ToLowerInvariant()] = @{                     LastWriteTimeUtc = $jf. LastWriteTimeUtc.ToString("o")                     Tamaño = $jf. Longitud                 }             }             Save-FileManifest -Manifest $stNewManifest -Path $stManifestPath             Write-Host " Manifiesto guardado para archivos $($stNewManifest.Count)" -ForegroundColor DarkGray         }         # LIMPIEZA DE RETENCIÓN         # Carpeta reutilizable del organizador (Aggregation_Current): mantener solo la última ejecución (1)         # Administración ejecuciones manuales u otras carpetas: mantener las últimas 7 ejecuciones         LOS CSV de resumen NUNCA se eliminan: son muy pequeños (~1 KB) y son el origen de copia de seguridad para el historial de tendencias         $outputLeaf = Split-Path $OutputPath -Hoja         $retentionCount = if ($outputLeaf -eq 'Aggregation_Current') { 1 } else { 7 }         # Prefijos de archivo seguros para limpiar (instantáneas efímeras por ejecución)         $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_'         )         # Buscar todas las marcas de tiempo únicas solo en archivos limpiables         $cleanableFiles = Get-ChildItem $OutputPath -File -EA SilentlyContinue |             Where-Object { $f = $_. Nombre; ($cleanupPrefixes | Where-Object { $f.StartsWith($_) }). Contar -gt 0 }         $allTimestamps = @($cleanableFiles | ForEach-Object {             si ($_. Name -match '(\d{8}-\d{6})') { $Matches[1] }         } | Sort-Object -Único -Descendente)         if ($allTimestamps.Count -gt $retentionCount) {             $oldTimestamps = $allTimestamps | Select-Object : $retentionCount saltar             $removedFiles = 0; $freedBytes = 0             foreach ($oldTs en $oldTimestamps) {                 foreach ($prefix en $cleanupPrefixes) {                     $oldFiles = Get-ChildItem $OutputPath -File -Filter "${prefix}${oldTs}*" -EA SilentlyContinue                     foreach ($f en $oldFiles) {                         $freedBytes += $f.Length                         Remove-Item $f.FullName -Force -EA SilentlyContinue                         $removedFiles++                     }                 }             }             $freedMB = [matemáticas]::Redondo($freedBytes / 1 MB, 1)             Write-Host "Limpieza de retención: se quitaron los archivos $removedFiles de $($oldTimestamps.Count) las antiguas ejecuciones, se liberaron ${freedMB} MB (mantener la última $retentionCount + todos los CSVs de resumen/NotUptodate)" -ForegroundColor DarkGray         }         Write-Host "'n$("=" * 60)" -ForegroundColor Cyan         Write-Host "STREAMING AGGREGATION COMPLETE" -ForegroundColor green         Write-Host ("=" * 60) -ForegroundColor Cyan         Write-Host " Total de dispositivos: $($c.Total.ToString("N0"))" -ForegroundColor White         Write-Host " NO ACTUALIZADO: $($stNotUptodate.ToString("N0")) ($($stats. PercentNotUptodate)%)" -ForegroundColor $(if ($stNotUptodate -gt 0) { "Yellow" } else { "Green" })         Write-Host " Actualizado: $($c.Updated.ToString("N0")) ($($stats. PercentCertUpdated)%)" -ForegroundColor green         Write-Host " Con errores: $($c.WithErrors.ToString("N0"))" -ForegroundColor $(if ($c.WithErrors -gt 0) { "Red" } else { "Green" })         Write-Host " Memoria máxima: ${stPeakMemMB} MB" -ForegroundColor Cyan         Write-Host " Hora: $([math]::Round($stTotal/60,1)) min" -ForegroundColor White         Write-Host " Panel: $htmlPath" -ForegroundColor White         return [PSCustomObject]$stats     }     #ENDREGION MODO DE STREAMING } else {     Write-Error "Ruta de entrada no encontrada: $InputPath"     salir 1 }                                                      

¿Necesita más ayuda?

¿Quiere más opciones?

Explore las ventajas de las suscripciones, examine los cursos de aprendizaje, aprenda a proteger su dispositivo y mucho más.